mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-24 18:56:39 +00:00
Compare commits
151 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
793883148a | ||
|
|
8acb7bea24 | ||
|
|
335cceeb82 | ||
|
|
bf7455ad6e | ||
|
|
421cc5b718 | ||
|
|
78d97a64aa | ||
|
|
749c757cbd | ||
|
|
faf9554286 | ||
|
|
b7a0be3872 | ||
|
|
cff8046ff8 | ||
|
|
af1289752d | ||
|
|
b06d9d3bc7 | ||
|
|
b2904189ef | ||
|
|
5c639d241b | ||
|
|
984e9f32a5 | ||
|
|
59d23b584a | ||
|
|
a7d865228a | ||
|
|
260ff716d7 | ||
|
|
9001a3da37 | ||
|
|
46db1e39f3 | ||
|
|
6bf3fc0fd5 | ||
|
|
a136543551 | ||
|
|
23046c149c | ||
|
|
2951d0d75e | ||
|
|
b52e40edd3 | ||
|
|
51556d76ac | ||
|
|
871868f0a4 | ||
|
|
67495fa302 | ||
|
|
fc9341f631 | ||
|
|
3fea8b5505 | ||
|
|
89e3114ef3 | ||
|
|
4dc5fad8b8 | ||
|
|
2567bdfdf0 | ||
|
|
f36cf1e7b9 | ||
|
|
bd88e56331 | ||
|
|
fe3e08de0f | ||
|
|
cfb165d240 | ||
|
|
fa074f91be | ||
|
|
6fc4963663 | ||
|
|
ad437f655e | ||
|
|
9b45513684 | ||
|
|
5d6d802d64 | ||
|
|
536d49aac9 | ||
|
|
796c9ca140 | ||
|
|
1b1a1f3230 | ||
|
|
98d856700c | ||
|
|
b814f500de | ||
|
|
90abf29db9 | ||
|
|
d064eb5f9e | ||
|
|
58c9ef9b51 | ||
|
|
125b13e059 | ||
|
|
dcdee8b308 | ||
|
|
f33d1fca39 | ||
|
|
7e907ba9b6 | ||
|
|
3cec2efbbd | ||
|
|
d4094e66b3 | ||
|
|
73b854037d | ||
|
|
f2e7a2161d | ||
|
|
260ed3041a | ||
|
|
8a146021dd | ||
|
|
4083592212 | ||
|
|
f9c57ca659 | ||
|
|
d0d664ef79 | ||
|
|
16d96efa4a | ||
|
|
f8ea1ae3d5 | ||
|
|
18883caa6d | ||
|
|
84fc82b74e | ||
|
|
8a9c694fbc | ||
|
|
4b33d39d44 | ||
|
|
c0f5d9c12c | ||
|
|
ef630af154 | ||
|
|
ebd7a76896 | ||
|
|
64a968711c | ||
|
|
aee4c2d02f | ||
|
|
8cc0695ee9 | ||
|
|
f40ad91ea9 | ||
|
|
a96539129d | ||
|
|
dcf72e6818 | ||
|
|
54290d4c9a | ||
|
|
eb3775859a | ||
|
|
83531666de | ||
|
|
f3a2535e2f | ||
|
|
f283dc8569 | ||
|
|
b19bbee7fc | ||
|
|
1b03d04318 | ||
|
|
6696fb13d6 | ||
|
|
f04aece7df | ||
|
|
d8f3952920 | ||
|
|
fefa4e7848 | ||
|
|
0b4a348969 | ||
|
|
3e2fee0df5 | ||
|
|
294888454d | ||
|
|
1b7e1e2b5b | ||
|
|
dc78138066 | ||
|
|
87e64e5899 | ||
|
|
e193a692e8 | ||
|
|
2eba607874 | ||
|
|
62df46d648 | ||
|
|
7c67fa4149 | ||
|
|
2db85c2783 | ||
|
|
39663ba936 | ||
|
|
eefea0c37b | ||
|
|
d65a6ba970 | ||
|
|
524b0a74c6 | ||
|
|
72de9d4ff8 | ||
|
|
a91f1b3bd4 | ||
|
|
343a93b984 | ||
|
|
8be17cce8a | ||
|
|
d2f818c1ea | ||
|
|
a675d60d59 | ||
|
|
2d96c21b50 | ||
|
|
6f6ba9e34d | ||
|
|
e6efda5563 | ||
|
|
b1df1652bf | ||
|
|
474239c151 | ||
|
|
feeb212259 | ||
|
|
90245016a0 | ||
|
|
8c7616c3a7 | ||
|
|
ea84ce9c41 | ||
|
|
c4730ec92d | ||
|
|
76b3d573c0 | ||
|
|
b96f4b7a90 | ||
|
|
2a0def262d | ||
|
|
897e35f0b8 | ||
|
|
1c335506d8 | ||
|
|
d46acdbd70 | ||
|
|
026bb4140e | ||
|
|
d80f3da55d | ||
|
|
f18495a4b1 | ||
|
|
f908da78f6 | ||
|
|
bc16381c90 | ||
|
|
4cb7aa64cf | ||
|
|
da6d7aea8b | ||
|
|
b310d79110 | ||
|
|
e26cdc11c3 | ||
|
|
fa54aa3128 | ||
|
|
e31e70039d | ||
|
|
cb761dea8f | ||
|
|
949e0da105 | ||
|
|
770cc59448 | ||
|
|
72dd2bd0a7 | ||
|
|
54733eaa18 | ||
|
|
52c56f7918 | ||
|
|
c46d5187c1 | ||
|
|
05e3e87653 | ||
|
|
8b9289ff08 | ||
|
|
16ffbcfbc0 | ||
|
|
d825b6e174 | ||
|
|
73e55cc742 | ||
|
|
32cc1cc580 | ||
|
|
e00574553f |
45
.eslintrc
45
.eslintrc
@@ -1,45 +1,28 @@
|
||||
{
|
||||
"extends": [
|
||||
"adidas-env/browser",
|
||||
"adidas-env/module",
|
||||
"adidas-env/node",
|
||||
"adidas-es6",
|
||||
"adidas-babel",
|
||||
"adidas-react"
|
||||
"@shlinkio/js-coding-standard"
|
||||
],
|
||||
"plugins": ["jest"],
|
||||
"env": {
|
||||
"jest/globals": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"tsconfigRootDir": ".",
|
||||
"createDefaultProgram": true
|
||||
},
|
||||
"globals": {
|
||||
"process": true,
|
||||
"setImmediate": true
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "16.3"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
"no-invalid-this": "off",
|
||||
"no-console": "warn",
|
||||
"template-curly-spacing": ["error", "never"],
|
||||
"no-warning-comments": "off",
|
||||
"no-magic-numbers": "off",
|
||||
"no-undefined": "off",
|
||||
"no-inline-comments": "off",
|
||||
"lines-around-comment": "off",
|
||||
"indent": ["error", 2, {
|
||||
"SwitchCase": 1
|
||||
}
|
||||
],
|
||||
"react/jsx-curly-spacing": ["error", "never"],
|
||||
"react/jsx-indent-props": ["error", 2],
|
||||
"react/jsx-first-prop-new-line": ["error", "multiline-multiprop"],
|
||||
"react/jsx-closing-bracket-location": ["error", "tag-aligned"],
|
||||
"react/no-array-index-key": "off",
|
||||
"react/no-did-update-set-state": "off",
|
||||
"react/display-name": "off"
|
||||
"max-len": ["error", {
|
||||
"code": 120,
|
||||
"ignoreStrings": true,
|
||||
"ignoreTemplateLiterals": true,
|
||||
"ignoreComments": true
|
||||
}],
|
||||
"no-mixed-operators": "off",
|
||||
"react/display-name": "off",
|
||||
"@typescript-eslint/require-array-sort-compare": "off"
|
||||
}
|
||||
}
|
||||
|
||||
5
.github/ISSUE_TEMPLATE.md
vendored
5
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,6 +1,7 @@
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be expected.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
-->
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/Bug.md
vendored
5
.github/ISSUE_TEMPLATE/Bug.md
vendored
@@ -5,9 +5,10 @@ labels: bug
|
||||
---
|
||||
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be expected.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
|
||||
With that said, please fill in the information requested next. More information might be requested once the issue is open.
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
5
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
@@ -5,9 +5,10 @@ labels: feature
|
||||
---
|
||||
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be expected.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
|
||||
With that said, please fill in the information requested next. More information might be requested once the issue is open.
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
5
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
@@ -5,9 +5,10 @@ labels: question
|
||||
---
|
||||
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be expected.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
|
||||
With that said, please fill in the information requested next. More information might be requested once the issue is open.
|
||||
|
||||
24
.github/workflows/docker-image-build.yml
vendored
Normal file
24
.github/workflows/docker-image-build.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Build docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Install buildx
|
||||
id: buildx
|
||||
uses: crazy-max/ghaction-docker-buildx@v1
|
||||
with:
|
||||
buildx-version: latest
|
||||
- name: Login to docker hub
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
- name: Build the image
|
||||
run: bash ./scripts/docker/build
|
||||
80
.travis.yml
80
.travis.yml
@@ -2,57 +2,51 @@ dist: bionic
|
||||
|
||||
language: node_js
|
||||
|
||||
jobs:
|
||||
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"
|
||||
branches:
|
||||
only:
|
||||
- /.*/
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- node_modules
|
||||
|
||||
services:
|
||||
- docker
|
||||
node_js:
|
||||
- '12.16.3'
|
||||
|
||||
install:
|
||||
- if [[ ${DOCKER_PUBLISH} == 'true' ]]; then sudo bash ./scripts/docker/install-docker ; fi
|
||||
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then npm ci ; fi
|
||||
jobs:
|
||||
fast_finish: true
|
||||
allow_failures:
|
||||
- name: 'Mutation tests'
|
||||
include:
|
||||
|
||||
before_script:
|
||||
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then echo "Building commit range ${TRAVIS_COMMIT_RANGE}" ; fi
|
||||
- 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
|
||||
- name: 'Lint'
|
||||
install: npm ci
|
||||
script: npm run lint
|
||||
|
||||
script:
|
||||
- if [[ ${DOCKER_PUBLISH} == 'true' ]]; then bash ./scripts/docker/build ; fi
|
||||
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then npm run lint ; fi
|
||||
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then npm run test: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
|
||||
- name: 'Unit tests'
|
||||
install: npm ci
|
||||
script: npm run test:ci
|
||||
after_success:
|
||||
- node_modules/.bin/ocular coverage/clover.xml
|
||||
|
||||
after_success:
|
||||
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then node_modules/.bin/ocular coverage/clover.xml ; fi
|
||||
- name: 'Mutation tests'
|
||||
install: npm ci
|
||||
before_script:
|
||||
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
|
||||
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/main} --name-only | grep -E 'src\/(.*).(ts|tsx)$' | paste -sd ",")
|
||||
script: npm run mutate:ci
|
||||
|
||||
# Before deploying, build dist file for current travis tag
|
||||
before_deploy:
|
||||
- if [[ ! -z $TRAVIS_TAG && ${DOCKER_PUBLISH} == 'false' ]]; then npm run build ${TRAVIS_TAG#?} ; fi
|
||||
- name: 'Build docker image'
|
||||
services:
|
||||
- docker
|
||||
script: docker build -t shlink-web-client:test .
|
||||
|
||||
deploy:
|
||||
- provider: releases
|
||||
api_key:
|
||||
secure: jBvPwC7EAbViaNR83rwMSt5XQDK0Iu9rgvEMa7GoyShbHcvUCCPd73Tu9quNpKi6NKsDY3INHgtch3vgonjGNGDGJ+yDyIBzXcvsAX2x3UcHpRbgY12uiINVmQxBI1+OVQB016Nm+cKC/i5Z36K4EmDbYfo+MrKndngM6AjcQFTwI8EwniIMaQgg4gNes//K8NhP5u0c3gwG+Q6jEGnq6uH3kcRgh6/epIZYpQyxjqWqKwF77sgcYj+X2Nf6XxtB5neuCi301UKLoLx8G0skh/Lm6KAIO4s9iIhIFa3UpoF21Ka0TxLpd2JxalLryCnFGlWWE6lxC9Htmc0TeRowJQlGdJXCskJ37xT9MljKY0fwNMu06VS/FUgykuCv+jP3zQu51pKu7Ew7+WeNPjautoOTu54VkdGyHcf2ThBNEyJQuiEwAQe4u7yAxY6R5ovEdvHBSIg4w1E5/Mxy5SMTCUlIAv6H7QQ1X9Z/zJm9HH5KeKz5tsHvQ/RIdSpgHXq/tC8o4Yup/LCFucXfrgvy/8pJoO1UpOlmvm62974NFfo0EG5YWwv6brUqz3QXpMjb8sWqgjltYMYJX3J7WZ34rIc+zt4NAmfhqgczaOC4pUGCiJ8jX3rMWIaQRn1AJ+5V337jL9fNDpTHny4phQjHrMJ1e0HZuNp0Xb5Q8wgqDPM=
|
||||
file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip"
|
||||
skip_cleanup: true
|
||||
on:
|
||||
all_branches: true
|
||||
condition: ${DOCKER_PUBLISH} == 'false'
|
||||
tags: true
|
||||
- name: 'Publish release'
|
||||
if: tag IS present
|
||||
before_deploy: npm run build ${TRAVIS_TAG#?}
|
||||
deploy:
|
||||
provider: releases
|
||||
api_key:
|
||||
secure: jBvPwC7EAbViaNR83rwMSt5XQDK0Iu9rgvEMa7GoyShbHcvUCCPd73Tu9quNpKi6NKsDY3INHgtch3vgonjGNGDGJ+yDyIBzXcvsAX2x3UcHpRbgY12uiINVmQxBI1+OVQB016Nm+cKC/i5Z36K4EmDbYfo+MrKndngM6AjcQFTwI8EwniIMaQgg4gNes//K8NhP5u0c3gwG+Q6jEGnq6uH3kcRgh6/epIZYpQyxjqWqKwF77sgcYj+X2Nf6XxtB5neuCi301UKLoLx8G0skh/Lm6KAIO4s9iIhIFa3UpoF21Ka0TxLpd2JxalLryCnFGlWWE6lxC9Htmc0TeRowJQlGdJXCskJ37xT9MljKY0fwNMu06VS/FUgykuCv+jP3zQu51pKu7Ew7+WeNPjautoOTu54VkdGyHcf2ThBNEyJQuiEwAQe4u7yAxY6R5ovEdvHBSIg4w1E5/Mxy5SMTCUlIAv6H7QQ1X9Z/zJm9HH5KeKz5tsHvQ/RIdSpgHXq/tC8o4Yup/LCFucXfrgvy/8pJoO1UpOlmvm62974NFfo0EG5YWwv6brUqz3QXpMjb8sWqgjltYMYJX3J7WZ34rIc+zt4NAmfhqgczaOC4pUGCiJ8jX3rMWIaQRn1AJ+5V337jL9fNDpTHny4phQjHrMJ1e0HZuNp0Xb5Q8wgqDPM=
|
||||
file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip"
|
||||
skip_cleanup: true
|
||||
|
||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -4,6 +4,67 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## 2.6.0 - 2020-09-20
|
||||
|
||||
#### Added
|
||||
|
||||
* [#289](https://github.com/shlinkio/shlink-web-client/issues/289) Client and server version constraints are now links to the corresponding project release notes.
|
||||
* [#293](https://github.com/shlinkio/shlink-web-client/issues/293) Shlink versions are now always displayed in footer, hiding the server version when there's no connected server.
|
||||
* [#250](https://github.com/shlinkio/shlink-web-client/issues/250) Added support to group real time updates in fixed intervals.
|
||||
|
||||
The settings page now allows to provide the interval in which the UI should get updated, making that happen at once, with all the updates that have happened during that interval.
|
||||
|
||||
By default updates are immediately applied if real-time updates are enabled, to keep the behavior as it was.
|
||||
|
||||
* [#277](https://github.com/shlinkio/shlink-web-client/issues/277) Added highlighting capabilities to the visits line chart.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#150](https://github.com/shlinkio/shlink-web-client/issues/150) The list of short URLs is now ordered by the creation date, showing newest results first.
|
||||
* [#248](https://github.com/shlinkio/shlink-web-client/issues/248) Numbers displayed application-wide are now prettified.
|
||||
* [#40](https://github.com/shlinkio/shlink-web-client/issues/40) Migrated project to TypeScript.
|
||||
* [#297](https://github.com/shlinkio/shlink-web-client/issues/297) Moved docker image building to github actions.
|
||||
* [#305](https://github.com/shlinkio/shlink-web-client/issues/305) Split travis build so that every step is run in a parallel job.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#295](https://github.com/shlinkio/shlink-web-client/issues/295) Fixed custom slug field not being disabled when selecting a short code length.
|
||||
* [#301](https://github.com/shlinkio/shlink-web-client/issues/301) Fixed tags visits loading not being cancelled when leaving visits page.
|
||||
|
||||
|
||||
## 2.5.1 - 2020-06-06
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#254](https://github.com/shlinkio/shlink-web-client/issues/254) Reduced duplication on code to handle mercure topics binding.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#276](https://github.com/shlinkio/shlink-web-client/issues/276) Fixed default grouping used for visits line chart, making it be dynamic depending on how old the short URL is.
|
||||
* [#280](https://github.com/shlinkio/shlink-web-client/issues/280) Fixed shlink-web-client version not being properly passed when building stable tags of the docker image.
|
||||
* [#269](https://github.com/shlinkio/shlink-web-client/issues/269) Fixed doughnut chart legends getting to big and hiding charts on mobile devices.
|
||||
|
||||
|
||||
## 2.5.0 - 2020-05-31
|
||||
|
||||
#### Added
|
||||
|
||||
72
CONTRIBUTING.md
Normal file
72
CONTRIBUTING.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Contributing
|
||||
|
||||
This file will guide you through the process of getting to project up and running, in case you want to provide coding contributions.
|
||||
|
||||
You will also see how to ensure the code fulfills the expected code checks, and how to create a pull request.
|
||||
|
||||
## System dependencies
|
||||
|
||||
The project can be run inside a docker container through provided docker-compose configuration.
|
||||
|
||||
Because of this, the only actual dependencies are [docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
|
||||
|
||||
## Setting up the project
|
||||
|
||||
The first thing you need to do is fork the repository, and clone it in your local machine.
|
||||
|
||||
Then you will have to follow these steps:
|
||||
|
||||
* Copy the file `docker-compose.override.yml.dist` by also removing the `dist` extension.
|
||||
* Start-up the project by running `docker-compose up`.
|
||||
|
||||
Once this is finished, you will have the project exposed in port `3000` (http://localhost:3000).
|
||||
|
||||
## Project structure
|
||||
|
||||
This project is a [react](https://reactjs.org/) & [redux](https://redux.js.org/) application, built with [typescript](https://www.typescriptlang.org/), which is distributed as a 100% client-side progressive web application.
|
||||
|
||||
This is the basic project structure:
|
||||
|
||||
```
|
||||
shlink-web-client
|
||||
├── config
|
||||
├── public
|
||||
├── scripts
|
||||
├── src
|
||||
├── test
|
||||
├── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
* `config`: It contains some configuration scripts, used during testing, linting and building of the project.
|
||||
* `public`: Will act as the application document root once built, and contains some static assets (favicons, images, etc).
|
||||
* `scripts`: It has some of the CLI scripts used to run tests or building.
|
||||
* `src`: Contains the main source code of the application, including both web components, SASS stylesheets and files with logic.
|
||||
* `test`: Contains the project tests.
|
||||
|
||||
## Running code checks
|
||||
|
||||
> Note: The `indocker` shell script is a helper used to run commands inside the docker container.
|
||||
|
||||
* `./indocker npm run lint`: Checks coding styles are fulfilled, both in JS/TS files as well as in stylesheets.
|
||||
* `./indocker npm run lint:js`: Checks coding styles are fulfilled in JS/TS files.
|
||||
* `./indocker npm run lint:css`: Checks coding styles are fulfilled in stylesheets.
|
||||
* `./indocker npm run lint:js:fix`: Fixes coding styles in JS/TS files.
|
||||
* `./indocker npm run lint:css:fix`: Fixes coding styles in stylesheets.
|
||||
* `./indocker npm run test`: Runs unit tests with Jest.
|
||||
* `./indocker npm run mutate`: Runs mutation tests with StrykerJS (this command can be very slow).
|
||||
|
||||
## Building the project
|
||||
|
||||
The source code in this project cannot be run directly in a web browser, you need to build it first.
|
||||
|
||||
* `./indocker npm run build`: Builds the project using a combination of `webpack`, `babel` and `tsc`, generating the final static files. The content is placed in the `build` folder, which is automatically created if it does not exist.
|
||||
* `./indocker npm run serve:build`: Serves the static files inside the `build` folder in port 5000 (http://localhost:5000). Useful to test the content built with previous command.
|
||||
|
||||
## Pull request process
|
||||
|
||||
In order to provide pull requests to this project, you should always start by creating a new branch, where you will make all desired changes.
|
||||
|
||||
The base branch should always be `main`, and the target branch for the pull request should also be `main`.
|
||||
|
||||
Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually, or wait for the build to be run automatically after the pull request is created.
|
||||
16
README.md
16
README.md
@@ -1,17 +1,19 @@
|
||||
# shlink-web-client
|
||||
|
||||
[](https://travis-ci.org/shlinkio/shlink-web-client)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/)
|
||||
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||
[](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE)
|
||||
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
||||
[](https://slnk.to/donate)
|
||||
|
||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||
|
||||

|
||||
|
||||
> If you are trying to find out how to run the project in development mode or how to provide contributions, read the [CONTRIBUTING](CONTRIBUTING.md) doc.
|
||||
|
||||
## Installation
|
||||
|
||||
There are three ways in which you can use this application.
|
||||
@@ -68,6 +70,14 @@ If you are using the shlink-web-client docker image, you can mount the `servers.
|
||||
|
||||
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/servers.json:/usr/share/nginx/html/servers.json shlinkio/shlink-web-client
|
||||
|
||||
> **Be extremely careful when using this feature.**
|
||||
>
|
||||
> Due to shlink-web-client's client-side nature, the file needs to be accessible from the browser.
|
||||
>
|
||||
> Because of that, make sure you use this only when you self-host shlink-web-client, and you know only trusted people will have access to it.
|
||||
>
|
||||
> Failing to do this could cause your API keys to end up being exposed.
|
||||
|
||||
## Serve project in subpath
|
||||
|
||||
Official distributable files have been built so that they are served from the root of a domain.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -10,7 +11,7 @@ const { NODE_ENV } = process.env;
|
||||
|
||||
if (!NODE_ENV) {
|
||||
throw new Error(
|
||||
'The NODE_ENV environment variable is required but was not specified.'
|
||||
'The NODE_ENV environment variable is required but was not specified.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +37,7 @@ dotenvFiles.forEach((dotenvFile) => {
|
||||
require('dotenv-expand')(
|
||||
require('dotenv').config({
|
||||
path: dotenvFile,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -82,7 +83,7 @@ function getClientEnvironment(publicUrl) {
|
||||
// This should only be used as an escape hatch. Normally you would put
|
||||
// images into the `src` and `import` them in code to get their paths.
|
||||
PUBLIC_URL: publicUrl,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Stringify all values so we can feed into Webpack DefinePlugin
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -75,7 +75,7 @@ module.exports = (webpackEnv) => {
|
||||
loader: MiniCssExtractPlugin.loader,
|
||||
options: Object.assign(
|
||||
{},
|
||||
shouldUseRelativeAssetPaths ? { publicPath: '../../' } : undefined
|
||||
shouldUseRelativeAssetPaths ? { publicPath: '../../' } : undefined,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -227,7 +227,7 @@ module.exports = (webpackEnv) => {
|
||||
|
||||
// Turned on because emoji and regex is not minified properly using default
|
||||
// https://github.com/facebook/create-react-app/issues/2488
|
||||
ascii_only: true,
|
||||
ascii_only: true, // eslint-disable-line @typescript-eslint/camelcase
|
||||
},
|
||||
},
|
||||
|
||||
@@ -281,7 +281,7 @@ module.exports = (webpackEnv) => {
|
||||
modules: [ 'node_modules' ].concat(
|
||||
|
||||
// It is guaranteed to exist because we tweak it in `env.js`
|
||||
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
|
||||
process.env.NODE_PATH.split(path.delimiter).filter(Boolean),
|
||||
),
|
||||
|
||||
// These are the reasonable defaults supported by the Node ecosystem.
|
||||
@@ -372,7 +372,7 @@ module.exports = (webpackEnv) => {
|
||||
loader: require.resolve('babel-loader'),
|
||||
options: {
|
||||
customize: require.resolve(
|
||||
'babel-preset-react-app/webpack-overrides'
|
||||
'babel-preset-react-app/webpack-overrides',
|
||||
),
|
||||
|
||||
plugins: [
|
||||
@@ -470,7 +470,7 @@ module.exports = (webpackEnv) => {
|
||||
importLoaders: 2,
|
||||
sourceMap: isEnvProduction && shouldUseSourceMap,
|
||||
},
|
||||
'sass-loader'
|
||||
'sass-loader',
|
||||
),
|
||||
|
||||
// Don't consider CSS imports dead code even if the
|
||||
@@ -491,7 +491,7 @@ module.exports = (webpackEnv) => {
|
||||
modules: true,
|
||||
getLocalIdent: getCSSModuleLocalIdent,
|
||||
},
|
||||
'sass-loader'
|
||||
'sass-loader',
|
||||
),
|
||||
},
|
||||
|
||||
@@ -544,8 +544,8 @@ module.exports = (webpackEnv) => {
|
||||
minifyURLs: true,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
|
||||
// Inlines the webpack runtime script. This script is too small to warrant
|
||||
@@ -668,7 +668,7 @@ module.exports = (webpackEnv) => {
|
||||
fs: 'empty',
|
||||
net: 'empty',
|
||||
tls: 'empty',
|
||||
child_process: 'empty',
|
||||
child_process: 'empty', // eslint-disable-line @typescript-eslint/camelcase
|
||||
},
|
||||
|
||||
// Turn off performance processing because we utilize
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
||||
|
||||
const fs = require('fs');
|
||||
const errorOverlayMiddleware = require('react-dev-utils/errorOverlayMiddleware');
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
module.exports = {
|
||||
coverageDirectory: '<rootDir>/coverage',
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.js',
|
||||
'src/**/*.{js,ts,tsx}',
|
||||
'!src/registerServiceWorker.js',
|
||||
'!src/index.js',
|
||||
'!src/reducers/index.js',
|
||||
'!src/**/provideServices.js',
|
||||
'!src/container/*.js',
|
||||
'!src/index.ts',
|
||||
'!src/reducers/index.ts',
|
||||
'!src/**/provideServices.ts',
|
||||
'!src/container/*.ts',
|
||||
],
|
||||
resolver: 'jest-pnp-resolver',
|
||||
setupFiles: [
|
||||
@@ -17,9 +17,9 @@ module.exports = {
|
||||
testEnvironment: 'jsdom',
|
||||
testURL: 'http://localhost',
|
||||
transform: {
|
||||
'^.+\\.(js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
|
||||
'^.+\\.(ts|tsx|js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
|
||||
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
|
||||
'^(?!.*\\.(js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
|
||||
'^(?!.*\\.(ts|tsx|js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
|
||||
|
||||
10073
package-lock.json
generated
10073
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
129
package.json
129
package.json
@@ -7,7 +7,7 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "npm run lint:js && npm run lint:css",
|
||||
"lint:js": "eslint src test scripts config",
|
||||
"lint:js": "eslint --ext .js,.ts,.tsx src test scripts config",
|
||||
"lint:js:fix": "npm run lint:js -- --fix",
|
||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||
"lint:css:fix": "npm run lint:css -- --fix",
|
||||
@@ -18,65 +18,84 @@
|
||||
"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",
|
||||
"mutate": "./node_modules/.bin/stryker run",
|
||||
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES",
|
||||
"check": "npm run test & npm run lint & wait"
|
||||
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.11.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.25",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.11.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.11.2",
|
||||
"@fortawesome/react-fontawesome": "^0.1.5",
|
||||
"@fortawesome/fontawesome-free": "^5.14.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.30",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.14.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.14.0",
|
||||
"@fortawesome/react-fontawesome": "^0.1.11",
|
||||
"array-filter": "^1.0.0",
|
||||
"array-map": "^0.0.0",
|
||||
"array-reduce": "^0.0.0",
|
||||
"axios": "^0.19.0",
|
||||
"bootstrap": "^4.3.1",
|
||||
"bottlejs": "^1.7.2",
|
||||
"bowser": "^2.9.0",
|
||||
"chart.js": "^2.8.0",
|
||||
"axios": "^0.20.0",
|
||||
"bootstrap": "^4.5.2",
|
||||
"bottlejs": "^2.0.0",
|
||||
"bowser": "^2.10.0",
|
||||
"chart.js": "^2.9.3",
|
||||
"classnames": "^2.2.6",
|
||||
"compare-versions": "^3.5.1",
|
||||
"compare-versions": "^3.6.0",
|
||||
"csvjson": "^5.1.0",
|
||||
"event-source-polyfill": "^1.0.12",
|
||||
"leaflet": "^1.5.1",
|
||||
"moment": "^2.24.0",
|
||||
"event-source-polyfill": "^1.0.17",
|
||||
"leaflet": "^1.7.1",
|
||||
"moment": "^2.27.0",
|
||||
"promise": "^8.0.3",
|
||||
"prop-types": "^15.7.2",
|
||||
"qs": "^6.9.0",
|
||||
"ramda": "^0.26.1",
|
||||
"qs": "^6.9.4",
|
||||
"ramda": "^0.27.1",
|
||||
"react": "^16.13.1",
|
||||
"react-autosuggest": "^9.4.3",
|
||||
"react-chartjs-2": "^2.8.0",
|
||||
"react-color": "^2.17.3",
|
||||
"react-copy-to-clipboard": "^5.0.1",
|
||||
"react-autosuggest": "^10.0.2",
|
||||
"react-chartjs-2": "^2.10.0",
|
||||
"react-color": "^2.18.1",
|
||||
"react-copy-to-clipboard": "^5.0.2",
|
||||
"react-datepicker": "~1.5.0",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-external-link": "^1.0.0",
|
||||
"react-leaflet": "^2.4.0",
|
||||
"react-moment": "^0.9.5",
|
||||
"react-redux": "^7.1.1",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"react-swipeable": "^5.4.0",
|
||||
"react-external-link": "^1.1.1",
|
||||
"react-leaflet": "^2.7.0",
|
||||
"react-moment": "^0.9.7",
|
||||
"react-redux": "^7.2.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-swipeable": "^5.5.1",
|
||||
"react-tagsinput": "^3.19.0",
|
||||
"reactstrap": "^8.0.1",
|
||||
"redux": "^4.0.4",
|
||||
"redux-actions": "^2.6.5",
|
||||
"redux-localstorage-simple": "^2.2.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"uuid": "^3.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.6.2",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.11.0",
|
||||
"@shlinkio/eslint-config-js-coding-standard": "~1.1.0",
|
||||
"@stryker-mutator/core": "^3.2.4",
|
||||
"@stryker-mutator/javascript-mutator": "^3.2.4",
|
||||
"@stryker-mutator/typescript": "^3.2.4",
|
||||
"@stryker-mutator/jest-runner": "^3.2.4",
|
||||
"@svgr/webpack": "^4.3.3",
|
||||
"@types/chart.js": "^2.9.24",
|
||||
"@types/classnames": "^2.2.10",
|
||||
"@types/enzyme": "^3.10.5",
|
||||
"@types/jest": "^26.0.10",
|
||||
"@types/leaflet": "^1.5.17",
|
||||
"@types/moment": "^2.13.0",
|
||||
"@types/qs": "^6.9.4",
|
||||
"@types/ramda": "^0.27.14",
|
||||
"@types/react": "^16.9.46",
|
||||
"@types/react-autosuggest": "^10.0.0",
|
||||
"@types/react-color": "^2.17.4",
|
||||
"@types/react-copy-to-clipboard": "^4.3.0",
|
||||
"@types/react-datepicker": "~1.8.0",
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"@types/react-leaflet": "^2.5.2",
|
||||
"@types/react-redux": "^7.1.9",
|
||||
"@types/react-router-dom": "^5.1.5",
|
||||
"@types/react-tagsinput": "^3.19.7",
|
||||
"@types/reactstrap": "^8.5.1",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"adm-zip": "^0.4.13",
|
||||
"autoprefixer": "^9.6.3",
|
||||
"babel-core": "7.0.0-bridge.0",
|
||||
"babel-eslint": "^10.0.3",
|
||||
"babel-jest": "^24.9.0",
|
||||
"babel-jest": "^26.3.0",
|
||||
"babel-loader": "^8.0.6",
|
||||
"babel-plugin-named-asset-import": "^0.3.4",
|
||||
"babel-preset-react-app": "^9.0.2",
|
||||
@@ -89,27 +108,18 @@
|
||||
"dotenv-expand": "^5.1.0",
|
||||
"enzyme": "^3.11.0",
|
||||
"enzyme-adapter-react-16": "^1.15.2",
|
||||
"eslint": "^5.11.1",
|
||||
"eslint-config-adidas-babel": "^1.1.0",
|
||||
"eslint-config-adidas-env": "^1.1.0",
|
||||
"eslint-config-adidas-es6": "^1.2.0",
|
||||
"eslint-config-adidas-react": "^1.1.1",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-loader": "^3.0.2",
|
||||
"eslint-plugin-import": "^2.18.2",
|
||||
"eslint-plugin-jest": "^22.17.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.2.3",
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.16.0",
|
||||
"file-loader": "^4.2.0",
|
||||
"fork-ts-checker-webpack-plugin-alt": "^0.4.14",
|
||||
"fs-extra": "^8.1.0",
|
||||
"html-webpack-plugin": "^4.0.0-beta.8",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^24.9.0",
|
||||
"jest-pnp-resolver": "^1.2.1",
|
||||
"jest-resolve": "^24.9.0",
|
||||
"jest": "^26.4.2",
|
||||
"jest-pnp-resolver": "^1.2.2",
|
||||
"jest-resolve": "^26.4.0",
|
||||
"mini-css-extract-plugin": "^0.8.0",
|
||||
"node-sass": "^4.12.0",
|
||||
"node-sass": "^4.14.1",
|
||||
"object-assign": "^4.1.1",
|
||||
"ocular.js": "^0.1.0",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
||||
@@ -120,20 +130,23 @@
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"postcss-safe-parser": "^4.0.1",
|
||||
"raf": "^3.4.1",
|
||||
"react-app-polyfill": "^1.0.4",
|
||||
"react-app-polyfill": "^1.0.6",
|
||||
"react-dev-utils": "^9.1.0",
|
||||
"resolve": "^1.12.0",
|
||||
"sass-loader": "^8.0.0",
|
||||
"serve": "^11.2.0",
|
||||
"sass-loader": "^10.0.2",
|
||||
"serve": "^11.3.2",
|
||||
"stryker-cli": "^1.0.0",
|
||||
"style-loader": "^1.0.0",
|
||||
"stylelint": "^9.10.1",
|
||||
"stylelint-config-adidas": "^1.2.1",
|
||||
"style-loader": "^1.2.1",
|
||||
"stylelint": "^13.7.0",
|
||||
"stylelint-config-adidas": "^1.3.0",
|
||||
"stylelint-config-adidas-bem": "^1.2.0",
|
||||
"stylelint-config-recommended-scss": "^4.0.0",
|
||||
"stylelint-scss": "^3.11.1",
|
||||
"stylelint-config-recommended-scss": "^4.2.0",
|
||||
"stylelint-scss": "^3.18.0",
|
||||
"sw-precache-webpack-plugin": "^0.11.5",
|
||||
"terser-webpack-plugin": "^2.1.2",
|
||||
"ts-jest": "^26.3.0",
|
||||
"ts-mockery": "^1.2.0",
|
||||
"typescript": "^3.9.7",
|
||||
"url-loader": "^2.2.0",
|
||||
"webpack": "^4.41.0",
|
||||
"webpack-dev-server": "^3.8.2",
|
||||
@@ -144,6 +157,10 @@
|
||||
"babel": {
|
||||
"presets": [
|
||||
"react-app"
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator"
|
||||
]
|
||||
},
|
||||
"browserslist": [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable no-console */
|
||||
/* eslint-disable no-console, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
||||
|
||||
// Do this as the first thing so that any code reading it knows the right env.
|
||||
process.env.BABEL_ENV = 'production';
|
||||
@@ -43,8 +43,8 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
|
||||
// Process CLI arguments
|
||||
const argvSliceStart = 2;
|
||||
const argv = process.argv.slice(argvSliceStart);
|
||||
const writeStatsJson = argv.indexOf('--stats') !== -1;
|
||||
const withoutDist = argv.indexOf('--no-dist') !== -1;
|
||||
const writeStatsJson = argv.includes('--stats');
|
||||
const withoutDist = argv.includes('--no-dist');
|
||||
const { version, hasVersion } = getVersionFromArgs(argv);
|
||||
|
||||
// Generate configuration
|
||||
@@ -75,12 +75,12 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
console.log(
|
||||
`\nSearch for the ${
|
||||
chalk.underline(chalk.yellow('keywords'))
|
||||
} to learn more about each warning.`
|
||||
} to learn more about each warning.`,
|
||||
);
|
||||
console.log(
|
||||
`To ignore, add ${
|
||||
chalk.cyan('// eslint-disable-next-line')
|
||||
} to the line before.\n`
|
||||
} to the line before.\n`,
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green('Compiled successfully.\n'));
|
||||
@@ -93,7 +93,7 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
previousFileSizes,
|
||||
paths.appBuild,
|
||||
WARN_AFTER_BUNDLE_GZIP_SIZE,
|
||||
WARN_AFTER_CHUNK_GZIP_SIZE
|
||||
WARN_AFTER_CHUNK_GZIP_SIZE,
|
||||
);
|
||||
console.log();
|
||||
},
|
||||
@@ -101,7 +101,7 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
console.log(chalk.red('Failed to compile.\n'));
|
||||
printBuildError(err);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
)
|
||||
.then(() => hasVersion && !withoutDist && zipDist(version))
|
||||
.catch((err) => {
|
||||
@@ -133,7 +133,7 @@ function build(previousFileSizes) {
|
||||
});
|
||||
} else {
|
||||
messages = formatWebpackMessages(
|
||||
stats.toJson({ all: false, warnings: true, errors: true })
|
||||
stats.toJson({ all: false, warnings: true, errors: true }),
|
||||
);
|
||||
}
|
||||
if (messages.errors.length) {
|
||||
@@ -154,8 +154,8 @@ function build(previousFileSizes) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'\nTreating warnings as errors because process.env.CI = true.\n' +
|
||||
'Most CI servers set it automatically.\n'
|
||||
)
|
||||
'Most CI servers set it automatically.\n',
|
||||
),
|
||||
);
|
||||
|
||||
return reject(new Error(messages.warnings.join('\n\n')));
|
||||
|
||||
@@ -1,33 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
set -ex
|
||||
|
||||
#PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
|
||||
# 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
|
||||
if [[ "$GITHUB_REF" == *"main"* ]]; then
|
||||
docker buildx build --push \
|
||||
--platform ${PLATFORMS} \
|
||||
-t ${DOCKER_IMAGE}:latest .
|
||||
|
||||
# If ref is not main, then this is a tag. Build that docker tag and also "stable"
|
||||
else
|
||||
TAGS="-t ${DOCKER_IMAGE}:${TRAVIS_TAG#?}"
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
TAGS="-t ${DOCKER_IMAGE}:${VERSION}"
|
||||
|
||||
# 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"
|
||||
[[ $GITHUB_REF != *"alpha"* && $GITHUB_REF != *"beta"* ]] && TAGS="${TAGS} -t ${DOCKER_IMAGE}:stable"
|
||||
|
||||
docker buildx build --push \
|
||||
--build-arg SHLINK_VERSION=${TRAVIS_TAG#?} \
|
||||
--build-arg VERSION=${VERSION} \
|
||||
--platform ${PLATFORMS} \
|
||||
${TAGS} .
|
||||
fi
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable no-console */
|
||||
/* eslint-disable no-console, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
||||
|
||||
// Do this as the first thing so that any code reading it knows the right env.
|
||||
process.env.BABEL_ENV = 'development';
|
||||
@@ -49,15 +49,15 @@ if (process.env.HOST) {
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
`Attempting to bind to HOST environment variable: ${chalk.yellow(
|
||||
chalk.bold(process.env.HOST)
|
||||
)}`
|
||||
)
|
||||
chalk.bold(process.env.HOST),
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
'If this was unintentional, check that you haven\'t mistakenly set it in your shell.'
|
||||
'If this was unintentional, check that you haven\'t mistakenly set it in your shell.',
|
||||
);
|
||||
console.log(
|
||||
`Learn more here: ${chalk.yellow('http://bit.ly/CRA-advanced-config')}`
|
||||
`Learn more here: ${chalk.yellow('http://bit.ly/CRA-advanced-config')}`,
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
@@ -91,7 +91,7 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
// Serve webpack assets generated by the compiler over a web server.
|
||||
const serverConfig = createDevServerConfig(
|
||||
proxyConfig,
|
||||
urls.lanUrlForConfig
|
||||
urls.lanUrlForConfig,
|
||||
);
|
||||
const devServer = new WebpackDevServer(compiler, serverConfig);
|
||||
|
||||
|
||||
12
shlink-web-client.d.ts
vendored
Normal file
12
shlink-web-client.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
declare module 'event-source-polyfill' {
|
||||
export const EventSourcePolyfill: any;
|
||||
}
|
||||
|
||||
declare module 'csvjson' {
|
||||
export declare class CsvJson {
|
||||
public toObject<T>(content: string): T[];
|
||||
public toCSV<T>(data: T[], options: { headers: string }): string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module '*.png'
|
||||
40
src/App.js
40
src/App.js
@@ -1,40 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import NotFound from './common/NotFound';
|
||||
import './App.scss';
|
||||
|
||||
const propTypes = {
|
||||
fetchServers: PropTypes.func,
|
||||
servers: PropTypes.object,
|
||||
};
|
||||
|
||||
const App = (MainHeader, Home, MenuLayout, CreateServer, EditServer, Settings) => ({ fetchServers, servers }) => {
|
||||
// On first load, try to fetch the remote servers if the list is empty
|
||||
useEffect(() => {
|
||||
if (Object.keys(servers).length === 0) {
|
||||
fetchServers();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container-fluid app-container">
|
||||
<MainHeader />
|
||||
|
||||
<div className="app">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
App.propTypes = propTypes;
|
||||
|
||||
export default App;
|
||||
16
src/App.scss
16
src/App.scss
@@ -8,3 +8,19 @@
|
||||
padding-top: $headerHeight;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.shlink-wrapper {
|
||||
min-height: 100%;
|
||||
padding-bottom: $footer-height + $footer-margin;
|
||||
margin-bottom: -($footer-height + $footer-margin);
|
||||
}
|
||||
|
||||
.shlink-footer {
|
||||
height: $footer-height;
|
||||
margin-top: $footer-margin;
|
||||
padding: 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 0 15px;
|
||||
}
|
||||
}
|
||||
|
||||
52
src/App.tsx
Normal file
52
src/App.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useEffect, FC } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import NotFound from './common/NotFound';
|
||||
import { ServersMap } from './servers/data';
|
||||
import './App.scss';
|
||||
|
||||
interface AppProps {
|
||||
fetchServers: Function;
|
||||
servers: ServersMap;
|
||||
}
|
||||
|
||||
const App = (
|
||||
MainHeader: FC,
|
||||
Home: FC,
|
||||
MenuLayout: FC,
|
||||
CreateServer: FC,
|
||||
EditServer: FC,
|
||||
Settings: FC,
|
||||
ShlinkVersions: FC,
|
||||
) => ({ fetchServers, servers }: AppProps) => {
|
||||
// On first load, try to fetch the remote servers if the list is empty
|
||||
useEffect(() => {
|
||||
if (Object.keys(servers).length === 0) {
|
||||
fetchServers();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container-fluid app-container">
|
||||
<MainHeader />
|
||||
|
||||
<div className="app">
|
||||
<div className="shlink-wrapper">
|
||||
<Switch>
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route exact path="/settings" component={Settings} />
|
||||
<Route exact path="/server/create" component={CreateServer} />
|
||||
<Route exact path="/server/:serverId/edit" component={EditServer} />
|
||||
<Route path="/server/:serverId" component={MenuLayout} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<div className="shlink-footer text-center text-md-right">
|
||||
<ShlinkVersions />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -1,81 +0,0 @@
|
||||
import {
|
||||
faList as listIcon,
|
||||
faLink as createIcon,
|
||||
faTags as tagsIcon,
|
||||
faPen as editIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import './AsideMenu.scss';
|
||||
|
||||
const AsideMenuItem = ({ children, to, className, ...rest }) => (
|
||||
<NavLink
|
||||
className={classNames('aside-menu__item', className)}
|
||||
activeClassName="aside-menu__item--selected"
|
||||
to={to}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
AsideMenuItem.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
to: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
const propTypes = {
|
||||
selectedServer: serverType,
|
||||
className: PropTypes.string,
|
||||
showOnMobile: PropTypes.bool,
|
||||
};
|
||||
|
||||
const AsideMenu = (DeleteServerButton) => {
|
||||
const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
|
||||
const serverId = selectedServer ? selectedServer.id : '';
|
||||
const asideClass = classNames('aside-menu', className, {
|
||||
'aside-menu--hidden': !showOnMobile,
|
||||
});
|
||||
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
|
||||
const buildPath = (suffix) => `/server/${serverId}${suffix}`;
|
||||
|
||||
return (
|
||||
<aside className={asideClass}>
|
||||
<nav className="nav flex-column aside-menu__nav">
|
||||
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
||||
<FontAwesomeIcon icon={listIcon} />
|
||||
<span className="aside-menu__item-text">List short URLs</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/create-short-url')}>
|
||||
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
|
||||
<span className="aside-menu__item-text">Create short URL</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||
<FontAwesomeIcon icon={tagsIcon} />
|
||||
<span className="aside-menu__item-text">Manage tags</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
||||
<FontAwesomeIcon icon={editIcon} />
|
||||
<span className="aside-menu__item-text">Edit this server</span>
|
||||
</AsideMenuItem>
|
||||
<DeleteServerButton
|
||||
className="aside-menu__item aside-menu__item--danger"
|
||||
textClassName="aside-menu__item-text"
|
||||
server={selectedServer}
|
||||
/>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
AsideMenu.propTypes = propTypes;
|
||||
|
||||
return AsideMenu;
|
||||
};
|
||||
|
||||
export default AsideMenu;
|
||||
@@ -18,7 +18,7 @@ $asideMenuMobileWidth: 280px;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 30px 15px 15px;
|
||||
border-right: 1px solid #eee;
|
||||
border-right: 1px solid #eeeeee;
|
||||
}
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
@@ -51,17 +51,17 @@ $asideMenuMobileWidth: 280px;
|
||||
}
|
||||
|
||||
.aside-menu__item--selected {
|
||||
color: #fff;
|
||||
color: #ffffff;
|
||||
background-color: $mainColor;
|
||||
}
|
||||
|
||||
.aside-menu__item--selected:hover {
|
||||
color: #fff;
|
||||
color: #ffffff;
|
||||
background-color: $mainColor;
|
||||
}
|
||||
|
||||
.aside-menu__item--divider {
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid #eeeeee;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ $asideMenuMobileWidth: 280px;
|
||||
}
|
||||
|
||||
.aside-menu__item--danger:hover {
|
||||
color: #fff;
|
||||
color: #ffffff;
|
||||
background-color: $dangerColor;
|
||||
}
|
||||
|
||||
|
||||
77
src/common/AsideMenu.tsx
Normal file
77
src/common/AsideMenu.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
faList as listIcon,
|
||||
faLink as createIcon,
|
||||
faTags as tagsIcon,
|
||||
faPen as editIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { FC } from 'react';
|
||||
import { NavLink, NavLinkProps } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import { Location } from 'history';
|
||||
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
||||
import { ServerWithId } from '../servers/data';
|
||||
import './AsideMenu.scss';
|
||||
|
||||
export interface AsideMenuProps {
|
||||
selectedServer: ServerWithId;
|
||||
className?: string;
|
||||
showOnMobile?: boolean;
|
||||
}
|
||||
|
||||
interface AsideMenuItemProps extends NavLinkProps {
|
||||
to: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
|
||||
<NavLink
|
||||
className={classNames('aside-menu__item', className)}
|
||||
activeClassName="aside-menu__item--selected"
|
||||
to={to}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||
{ selectedServer, className, showOnMobile = false }: AsideMenuProps,
|
||||
) => {
|
||||
const serverId = selectedServer ? selectedServer.id : '';
|
||||
const asideClass = classNames('aside-menu', className, {
|
||||
'aside-menu--hidden': !showOnMobile,
|
||||
});
|
||||
const shortUrlsIsActive = (_: null, location: Location) => location.pathname.match('/list-short-urls') !== null;
|
||||
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
|
||||
|
||||
return (
|
||||
<aside className={asideClass}>
|
||||
<nav className="nav flex-column aside-menu__nav">
|
||||
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
||||
<FontAwesomeIcon icon={listIcon} />
|
||||
<span className="aside-menu__item-text">List short URLs</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/create-short-url')}>
|
||||
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
|
||||
<span className="aside-menu__item-text">Create short URL</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||
<FontAwesomeIcon icon={tagsIcon} />
|
||||
<span className="aside-menu__item-text">Manage tags</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
||||
<FontAwesomeIcon icon={editIcon} />
|
||||
<span className="aside-menu__item-text">Edit this server</span>
|
||||
</AsideMenuItem>
|
||||
<DeleteServerButton
|
||||
className="aside-menu__item aside-menu__item--danger"
|
||||
textClassName="aside-menu__item-text"
|
||||
server={selectedServer}
|
||||
/>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default AsideMenu;
|
||||
@@ -1,30 +1,31 @@
|
||||
import React from 'react';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import './ErrorHandler.scss';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Button } from 'reactstrap';
|
||||
import './ErrorHandler.scss';
|
||||
|
||||
// FIXME Replace with typescript: (window, console)
|
||||
const ErrorHandler = ({ location }, { error }) => class ErrorHandler extends React.Component {
|
||||
static propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
interface ErrorHandlerState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
const ErrorHandler = (
|
||||
{ location }: Window,
|
||||
{ error }: Console,
|
||||
) => class ErrorHandler extends React.Component<any, ErrorHandlerState> {
|
||||
public constructor(props: object) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
public static getDerivedStateFromError(): ErrorHandlerState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(e) {
|
||||
public componentDidCatch(e: Error): void {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
error(e);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
public render(): ReactNode | undefined {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-handler">
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
.home {
|
||||
text-align: center;
|
||||
height: calc(100vh - #{$headerHeight});
|
||||
height: calc(100vh - #{$headerHeight} - #{($footer-height + $footer-margin)});
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { isEmpty, values } from 'ramda';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Home.scss';
|
||||
import ServersListGroup from '../servers/ServersListGroup';
|
||||
import './Home.scss';
|
||||
import { ServersMap } from '../servers/data';
|
||||
|
||||
const propTypes = {
|
||||
resetSelectedServer: PropTypes.func,
|
||||
servers: PropTypes.object,
|
||||
};
|
||||
export interface HomeProps {
|
||||
servers: ServersMap;
|
||||
}
|
||||
|
||||
const Home = ({ resetSelectedServer, servers }) => {
|
||||
const Home = ({ servers }: HomeProps) => {
|
||||
const serversList = values(servers);
|
||||
const hasServers = !isEmpty(serversList);
|
||||
|
||||
useEffect(() => {
|
||||
resetSelectedServer();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<h1 className="home__title">Welcome to Shlink</h1>
|
||||
@@ -29,6 +24,4 @@ const Home = ({ resetSelectedServer, servers }) => {
|
||||
);
|
||||
};
|
||||
|
||||
Home.propTypes = propTypes;
|
||||
|
||||
export default Home;
|
||||
@@ -1,61 +0,0 @@
|
||||
import { faPlus as plusIcon, faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import shlinkLogo from './shlink-logo-white.png';
|
||||
import './MainHeader.scss';
|
||||
|
||||
const propTypes = {
|
||||
location: PropTypes.object,
|
||||
};
|
||||
|
||||
const MainHeader = (ServersDropdown) => {
|
||||
const MainHeaderComp = ({ location }) => {
|
||||
const [ isOpen, toggleOpen, , close ] = useToggle();
|
||||
const { pathname } = location;
|
||||
|
||||
useEffect(close, [ location ]);
|
||||
|
||||
const createServerPath = '/server/create';
|
||||
const settingsPath = '/settings';
|
||||
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
||||
|
||||
return (
|
||||
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
||||
<NavbarBrand tag={Link} to="/">
|
||||
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
|
||||
</NavbarBrand>
|
||||
|
||||
<NavbarToggler onClick={toggleOpen}>
|
||||
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
||||
</NavbarToggler>
|
||||
|
||||
<Collapse navbar isOpen={isOpen}>
|
||||
<Nav navbar className="ml-auto">
|
||||
<NavItem>
|
||||
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink tag={Link} to={createServerPath} active={pathname === createServerPath}>
|
||||
<FontAwesomeIcon icon={plusIcon} /> Add server
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<ServersDropdown />
|
||||
</Nav>
|
||||
</Collapse>
|
||||
</Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
MainHeaderComp.propTypes = propTypes;
|
||||
|
||||
return MainHeaderComp;
|
||||
};
|
||||
|
||||
export default MainHeader;
|
||||
51
src/common/MainHeader.tsx
Normal file
51
src/common/MainHeader.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { faPlus as plusIcon, faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||
import classNames from 'classnames';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import shlinkLogo from './shlink-logo-white.png';
|
||||
import './MainHeader.scss';
|
||||
|
||||
const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps) => {
|
||||
const [ isOpen, toggleOpen, , close ] = useToggle();
|
||||
const { pathname } = location;
|
||||
|
||||
useEffect(close, [ location ]);
|
||||
|
||||
const createServerPath = '/server/create';
|
||||
const settingsPath = '/settings';
|
||||
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
||||
|
||||
return (
|
||||
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
||||
<NavbarBrand tag={Link} to="/">
|
||||
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
|
||||
</NavbarBrand>
|
||||
|
||||
<NavbarToggler onClick={toggleOpen}>
|
||||
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
||||
</NavbarToggler>
|
||||
|
||||
<Collapse navbar isOpen={isOpen}>
|
||||
<Nav navbar className="ml-auto">
|
||||
<NavItem>
|
||||
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink tag={Link} to={createServerPath} active={pathname === createServerPath}>
|
||||
<FontAwesomeIcon icon={plusIcon} /> Add server
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<ServersDropdown />
|
||||
</Nav>
|
||||
</Collapse>
|
||||
</Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainHeader;
|
||||
@@ -1,98 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import { Swipeable } from 'react-swipeable';
|
||||
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { versionMatch } from '../utils/helpers/version';
|
||||
import NotFound from './NotFound';
|
||||
import './MenuLayout.scss';
|
||||
|
||||
const propTypes = {
|
||||
match: PropTypes.object,
|
||||
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();
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
||||
|
||||
<Swipeable
|
||||
delta={40}
|
||||
className="menu-layout__swipeable"
|
||||
onSwipedLeft={swipeMenuIfNoModalExists(hideSidebar)}
|
||||
onSwipedRight={swipeMenuIfNoModalExists(showSidebar)}
|
||||
>
|
||||
<div className="row menu-layout__swipeable-inner">
|
||||
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
||||
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
|
||||
<div className="menu-layout__container">
|
||||
<Switch>
|
||||
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
||||
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
||||
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||
{addTagsVisitsRoute && <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||
<Route
|
||||
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<div className="menu-layout__footer text-center text-md-right">
|
||||
<ShlinkVersions />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Swipeable>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
MenuLayoutComp.propTypes = propTypes;
|
||||
|
||||
return withSelectedServer(MenuLayoutComp, ServerError);
|
||||
};
|
||||
|
||||
export default MenuLayout;
|
||||
@@ -33,25 +33,11 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
$footer-height: 2.3rem;
|
||||
$footer-margin: .8rem;
|
||||
|
||||
.menu-layout__container {
|
||||
padding: 20px 0 ($footer-height + $footer-margin);
|
||||
padding: 20px 0 0;
|
||||
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;
|
||||
padding: 30px 15px 0;
|
||||
}
|
||||
}
|
||||
|
||||
80
src/common/MenuLayout.tsx
Normal file
80
src/common/MenuLayout.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import { EventData, Swipeable } from 'react-swipeable';
|
||||
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { versionMatch } from '../utils/helpers/version';
|
||||
import { isReachableServer } from '../servers/data';
|
||||
import NotFound from './NotFound';
|
||||
import { AsideMenuProps } from './AsideMenu';
|
||||
import './MenuLayout.scss';
|
||||
|
||||
const MenuLayout = (
|
||||
TagsList: FC,
|
||||
ShortUrls: FC,
|
||||
AsideMenu: FC<AsideMenuProps>,
|
||||
CreateShortUrl: FC,
|
||||
ShortUrlVisits: FC,
|
||||
TagVisits: FC,
|
||||
ServerError: FC,
|
||||
) => withSelectedServer(({ location, selectedServer }) => {
|
||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
||||
|
||||
useEffect(() => hideSidebar(), [ location ]);
|
||||
|
||||
if (!isReachableServer(selectedServer)) {
|
||||
return <ServerError />;
|
||||
}
|
||||
|
||||
const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' });
|
||||
const burgerClasses = classNames('menu-layout__burger-icon', {
|
||||
'menu-layout__burger-icon--active': sidebarVisible,
|
||||
});
|
||||
const swipeMenuIfNoModalExists = (callback: () => void) => (e: EventData) => {
|
||||
const swippedOnVisitsTable = (e.event.composedPath() as HTMLElement[]).some(
|
||||
({ classList }) => classList?.contains('visits-table'),
|
||||
);
|
||||
|
||||
if (swippedOnVisitsTable || document.querySelector('.modal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
||||
|
||||
<Swipeable
|
||||
delta={40}
|
||||
className="menu-layout__swipeable"
|
||||
onSwipedLeft={swipeMenuIfNoModalExists(hideSidebar)}
|
||||
onSwipedRight={swipeMenuIfNoModalExists(showSidebar)}
|
||||
>
|
||||
<div className="row menu-layout__swipeable-inner">
|
||||
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
||||
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
|
||||
<div className="menu-layout__container">
|
||||
<Switch>
|
||||
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
||||
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
||||
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||
{addTagsVisitsRoute && <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||
<Route
|
||||
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Swipeable>
|
||||
</React.Fragment>
|
||||
);
|
||||
}, ServerError);
|
||||
|
||||
export default MenuLayout;
|
||||
@@ -1,13 +0,0 @@
|
||||
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;
|
||||
@@ -1,3 +1,3 @@
|
||||
.no-menu-wrapper {
|
||||
padding: 40px 20px;
|
||||
padding: 40px 20px 20px;
|
||||
}
|
||||
|
||||
6
src/common/NoMenuLayout.tsx
Normal file
6
src/common/NoMenuLayout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React, { FC } from 'react';
|
||||
import './NoMenuLayout.scss';
|
||||
|
||||
const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper">{children}</div>;
|
||||
|
||||
export default NoMenuLayout;
|
||||
@@ -1,13 +1,11 @@
|
||||
import React from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import * as PropTypes from 'prop-types';
|
||||
|
||||
const propTypes = {
|
||||
to: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
interface NotFoundProps {
|
||||
to?: string;
|
||||
}
|
||||
|
||||
const NotFound = ({ to = '/', children = 'Home' }) => (
|
||||
const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
||||
<div className="home">
|
||||
<h2>Oops! We could not find requested route.</h2>
|
||||
<p>
|
||||
@@ -19,6 +17,4 @@ const NotFound = ({ to = '/', children = 'Home' }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
NotFound.propTypes = propTypes;
|
||||
|
||||
export default NotFound;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const propTypes = {
|
||||
location: PropTypes.object,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
const ScrollToTop = () => {
|
||||
const ScrollToTopComp = ({ location, children }) => {
|
||||
useEffect(() => {
|
||||
scrollTo(0, 0);
|
||||
}, [ location ]);
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
ScrollToTopComp.propTypes = propTypes;
|
||||
|
||||
return ScrollToTopComp;
|
||||
};
|
||||
|
||||
export default ScrollToTop;
|
||||
12
src/common/ScrollToTop.tsx
Normal file
12
src/common/ScrollToTop.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React, { PropsWithChildren, useEffect } from 'react';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
|
||||
const ScrollToTop = () => ({ location, children }: PropsWithChildren<RouteComponentProps>) => {
|
||||
useEffect(() => {
|
||||
scrollTo(0, 0);
|
||||
}, [ location ]);
|
||||
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
};
|
||||
|
||||
export default ScrollToTop;
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { pipe } from 'ramda';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
||||
|
||||
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
||||
|
||||
const propTypes = {
|
||||
selectedServer: serverType,
|
||||
className: PropTypes.string,
|
||||
clientVersion: PropTypes.string,
|
||||
};
|
||||
|
||||
const ShlinkVersions = ({ selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }) => {
|
||||
const { printableVersion: serverVersion } = selectedServer;
|
||||
const normalizedClientVersion = pipe(versionToSemVer(), versionToPrintable)(clientVersion);
|
||||
|
||||
return (
|
||||
<small className={classNames('text-muted', className)}>
|
||||
Client: <b>{normalizedClientVersion}</b> - Server: <b>{serverVersion}</b>
|
||||
</small>
|
||||
);
|
||||
};
|
||||
|
||||
ShlinkVersions.propTypes = propTypes;
|
||||
|
||||
export default ShlinkVersions;
|
||||
38
src/common/ShlinkVersions.tsx
Normal file
38
src/common/ShlinkVersions.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { pipe } from 'ramda';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||
|
||||
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
||||
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
|
||||
|
||||
export interface ShlinkVersionsProps {
|
||||
selectedServer: SelectedServer;
|
||||
clientVersion?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
|
||||
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="text-muted">
|
||||
<b>{version}</b>
|
||||
</ExternalLink>
|
||||
);
|
||||
|
||||
const ShlinkVersions = (
|
||||
{ selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps,
|
||||
) => {
|
||||
const normalizedClientVersion = normalizeVersion(clientVersion);
|
||||
|
||||
return (
|
||||
<small className={classNames('text-muted', className)}>
|
||||
{isReachableServer(selectedServer) &&
|
||||
<React.Fragment>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </React.Fragment>
|
||||
}
|
||||
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} />
|
||||
</small>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShlinkVersions;
|
||||
@@ -1,23 +1,28 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { FC } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
|
||||
import {
|
||||
pageIsEllipsis,
|
||||
keyForPage,
|
||||
NumberOrEllipsis,
|
||||
progressivePagination,
|
||||
prettifyPageNumber,
|
||||
} from '../utils/helpers/pagination';
|
||||
import './SimplePaginator.scss';
|
||||
|
||||
const propTypes = {
|
||||
pagesCount: PropTypes.number.isRequired,
|
||||
currentPage: PropTypes.number.isRequired,
|
||||
setCurrentPage: PropTypes.func.isRequired,
|
||||
centered: PropTypes.bool,
|
||||
};
|
||||
interface SimplePaginatorProps {
|
||||
pagesCount: number;
|
||||
currentPage: number;
|
||||
setCurrentPage: (currentPage: number) => void;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage, centered = true }) => {
|
||||
const SimplePaginator: FC<SimplePaginatorProps> = ({ pagesCount, currentPage, setCurrentPage, centered = true }) => {
|
||||
if (pagesCount < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onClick = (page) => () => setCurrentPage(page);
|
||||
const onClick = (page: NumberOrEllipsis) => () => !pageIsEllipsis(page) && setCurrentPage(page);
|
||||
|
||||
return (
|
||||
<Pagination listClassName={classNames('flex-wrap mb-0 simple-paginator', { 'justify-content-center': centered })}>
|
||||
@@ -27,10 +32,10 @@ const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage, centered = t
|
||||
{progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
|
||||
<PaginationItem
|
||||
key={keyForPage(pageNumber, index)}
|
||||
disabled={isPageDisabled(pageNumber)}
|
||||
disabled={pageIsEllipsis(pageNumber)}
|
||||
active={currentPage === pageNumber}
|
||||
>
|
||||
<PaginationLink tag="span" onClick={onClick(pageNumber)}>{pageNumber}</PaginationLink>
|
||||
<PaginationLink tag="span" onClick={onClick(pageNumber)}>{prettifyPageNumber(pageNumber)}</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem disabled={currentPage >= pagesCount}>
|
||||
@@ -40,6 +45,4 @@ const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage, centered = t
|
||||
);
|
||||
};
|
||||
|
||||
SimplePaginator.propTypes = propTypes;
|
||||
|
||||
export default SimplePaginator;
|
||||
@@ -1,6 +1,6 @@
|
||||
.react-tagsinput {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #cccccc;
|
||||
border-radius: .25rem;
|
||||
overflow: hidden;
|
||||
min-height: 2.6rem;
|
||||
@@ -22,7 +22,7 @@
|
||||
margin: 0 5px 6px 0;
|
||||
padding: 6px 8px;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.react-tagsinput-remove {
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
.react-tagsinput-tag span:before {
|
||||
content: '\2715';
|
||||
color: #fff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.react-tagsinput-input {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Bottle, { Decorator } from 'bottlejs';
|
||||
import ScrollToTop from '../ScrollToTop';
|
||||
import MainHeader from '../MainHeader';
|
||||
import Home from '../Home';
|
||||
@@ -5,18 +6,21 @@ import MenuLayout from '../MenuLayout';
|
||||
import AsideMenu from '../AsideMenu';
|
||||
import ErrorHandler from '../ErrorHandler';
|
||||
import ShlinkVersions from '../ShlinkVersions';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||
|
||||
const provideServices = (bottle, connect, withRouter) => {
|
||||
bottle.constant('window', global.window);
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||
bottle.constant('window', (global as any).window);
|
||||
bottle.constant('console', global.console);
|
||||
|
||||
bottle.serviceFactory('ScrollToTop', ScrollToTop, 'window');
|
||||
bottle.serviceFactory('ScrollToTop', ScrollToTop);
|
||||
bottle.decorator('ScrollToTop', withRouter);
|
||||
|
||||
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
|
||||
bottle.decorator('MainHeader', withRouter);
|
||||
|
||||
bottle.serviceFactory('Home', () => Home);
|
||||
bottle.decorator('Home', withoutSelectedServer);
|
||||
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
|
||||
|
||||
bottle.serviceFactory(
|
||||
@@ -28,8 +32,7 @@ const provideServices = (bottle, connect, withRouter) => {
|
||||
'CreateShortUrl',
|
||||
'ShortUrlVisits',
|
||||
'TagVisits',
|
||||
'ShlinkVersions',
|
||||
'ServerError'
|
||||
'ServerError',
|
||||
);
|
||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
||||
bottle.decorator('MenuLayout', withRouter);
|
||||
@@ -1,4 +1,4 @@
|
||||
import Bottle from 'bottlejs';
|
||||
import Bottle, { IContainer } from 'bottlejs';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { connect as reduxConnect } from 'react-redux';
|
||||
import { pick } from 'ramda';
|
||||
@@ -11,24 +11,26 @@ import provideTagsServices from '../tags/services/provideServices';
|
||||
import provideUtilsServices from '../utils/services/provideServices';
|
||||
import provideMercureServices from '../mercure/services/provideServices';
|
||||
import provideSettingsServices from '../settings/services/provideServices';
|
||||
import { ConnectDecorator } from './types';
|
||||
|
||||
type LazyActionMap = Record<string, Function>;
|
||||
|
||||
const bottle = new Bottle();
|
||||
const { container } = bottle;
|
||||
|
||||
const lazyService = (container, serviceName) => (...args) => container[serviceName](...args);
|
||||
const mapActionService = (map, actionName) => ({
|
||||
const lazyService = (container: IContainer, serviceName: string) => (...args: any[]) => container[serviceName](...args);
|
||||
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
|
||||
...map,
|
||||
|
||||
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
||||
[actionName]: lazyService(container, actionName),
|
||||
});
|
||||
const connect = (propsFromState, actionServiceNames = []) =>
|
||||
const connect: ConnectDecorator = (propsFromState: string[] | null, actionServiceNames: string[] = []) =>
|
||||
reduxConnect(
|
||||
propsFromState ? pick(propsFromState) : null,
|
||||
actionServiceNames.reduce(mapActionService, {})
|
||||
actionServiceNames.reduce(mapActionService, {}),
|
||||
);
|
||||
|
||||
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer', 'Settings');
|
||||
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer', 'Settings', 'ShlinkVersions');
|
||||
bottle.decorator('App', connect([ 'servers' ], [ 'fetchServers' ]));
|
||||
|
||||
provideCommonServices(bottle, connect, withRouter);
|
||||
@@ -1,13 +1,12 @@
|
||||
import ReduxThunk from 'redux-thunk';
|
||||
import { applyMiddleware, compose, createStore } from 'redux';
|
||||
import { save, load } from 'redux-localstorage-simple';
|
||||
import { save, load, RLSOptions } from 'redux-localstorage-simple';
|
||||
import reducers from '../reducers';
|
||||
|
||||
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
||||
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
||||
: compose;
|
||||
const isProduction = process.env.NODE_ENV !== 'production';
|
||||
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||
|
||||
const localStorageConfig = {
|
||||
const localStorageConfig: RLSOptions = {
|
||||
states: [ 'settings', 'servers' ],
|
||||
namespace: 'shlink',
|
||||
namespaceSeparator: '.',
|
||||
@@ -15,7 +14,7 @@ const localStorageConfig = {
|
||||
};
|
||||
|
||||
const store = createStore(reducers, load(localStorageConfig), composeEnhancers(
|
||||
applyMiddleware(save(localStorageConfig), ReduxThunk)
|
||||
applyMiddleware(save(localStorageConfig), ReduxThunk),
|
||||
));
|
||||
|
||||
export default store;
|
||||
40
src/container/types.ts
Normal file
40
src/container/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { MercureInfo } from '../mercure/reducers/mercureInfo';
|
||||
import { SelectedServer, ServersMap } from '../servers/data';
|
||||
import { Settings } from '../settings/reducers/settings';
|
||||
import { ShortUrlMetaEdition } from '../short-urls/reducers/shortUrlMeta';
|
||||
import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
|
||||
import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
|
||||
import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
|
||||
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
|
||||
import { ShortUrlTags } from '../short-urls/reducers/shortUrlTags';
|
||||
import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
|
||||
import { TagDeletion } from '../tags/reducers/tagDelete';
|
||||
import { TagEdition } from '../tags/reducers/tagEdit';
|
||||
import { TagsList } from '../tags/reducers/tagsList';
|
||||
import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail';
|
||||
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
||||
import { TagVisits } from '../visits/reducers/tagVisits';
|
||||
|
||||
export interface ShlinkState {
|
||||
servers: ServersMap;
|
||||
selectedServer: SelectedServer;
|
||||
shortUrlsList: ShortUrlsList;
|
||||
shortUrlsListParams: ShortUrlsListParams;
|
||||
shortUrlCreationResult: ShortUrlCreation;
|
||||
shortUrlDeletion: ShortUrlDeletion;
|
||||
shortUrlTags: ShortUrlTags;
|
||||
shortUrlMeta: ShortUrlMetaEdition;
|
||||
shortUrlEdition: ShortUrlEdition;
|
||||
shortUrlVisits: ShortUrlVisits;
|
||||
tagVisits: TagVisits;
|
||||
shortUrlDetail: ShortUrlDetail;
|
||||
tagsList: TagsList;
|
||||
tagDelete: TagDeletion;
|
||||
tagEdit: TagEdition;
|
||||
mercureInfo: MercureInfo;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||
|
||||
export type GetState = () => ShlinkState;
|
||||
@@ -25,7 +25,7 @@ body,
|
||||
}
|
||||
|
||||
.badge-main {
|
||||
color: #fff;
|
||||
color: #ffffff;
|
||||
background-color: $mainColor;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import React from 'react';
|
||||
import { render } from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
@@ -7,7 +6,8 @@ import { homepage } from '../package.json';
|
||||
import registerServiceWorker from './registerServiceWorker';
|
||||
import container from './container';
|
||||
import store from './container/store';
|
||||
import { fixLeafletIcons } from './utils/utils';
|
||||
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import './common/react-tagsinput.scss';
|
||||
@@ -28,6 +28,6 @@ render(
|
||||
</ErrorHandler>
|
||||
</BrowserRouter>
|
||||
</Provider>,
|
||||
document.getElementById('root')
|
||||
document.getElementById('root'),
|
||||
);
|
||||
registerServiceWorker();
|
||||
41
src/mercure/helpers/boundToMercureHub.tsx
Normal file
41
src/mercure/helpers/boundToMercureHub.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import { pipe } from 'ramda';
|
||||
import { CreateVisit } from '../../visits/types';
|
||||
import { MercureInfo } from '../reducers/mercureInfo';
|
||||
import { bindToMercureTopic } from './index';
|
||||
|
||||
export interface MercureBoundProps {
|
||||
createNewVisits: (createdVisits: CreateVisit[]) => void;
|
||||
loadMercureInfo: Function;
|
||||
mercureInfo: MercureInfo;
|
||||
}
|
||||
|
||||
export function boundToMercureHub<T = {}>(
|
||||
WrappedComponent: FC<MercureBoundProps & T>,
|
||||
getTopicForProps: (props: T) => string,
|
||||
) {
|
||||
const pendingUpdates = new Set<CreateVisit>();
|
||||
|
||||
return (props: MercureBoundProps & T) => {
|
||||
const { createNewVisits, loadMercureInfo, mercureInfo } = props;
|
||||
const { interval } = mercureInfo;
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]);
|
||||
const closeEventSource = bindToMercureTopic(mercureInfo, getTopicForProps(props), onMessage, loadMercureInfo);
|
||||
|
||||
if (!interval) {
|
||||
return closeEventSource;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
createNewVisits([ ...pendingUpdates ]);
|
||||
pendingUpdates.clear();
|
||||
}, interval * 1000 * 60);
|
||||
|
||||
return pipe(() => clearInterval(timer), () => closeEventSource?.());
|
||||
}, [ mercureInfo ]);
|
||||
|
||||
return <WrappedComponent {...props} />;
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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();
|
||||
};
|
||||
24
src/mercure/helpers/index.ts
Normal file
24
src/mercure/helpers/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
|
||||
import { MercureInfo } from '../reducers/mercureInfo';
|
||||
|
||||
export const bindToMercureTopic = <T>(mercureInfo: MercureInfo, topic: string, onMessage: (message: T) => void, onTokenExpired: Function) => { // eslint-disable-line max-len
|
||||
const { mercureHubUrl, token, loading, error } = mercureInfo;
|
||||
|
||||
if (loading || error || !mercureHubUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hubUrl = new URL(mercureHubUrl);
|
||||
|
||||
hubUrl.searchParams.append('topic', topic);
|
||||
const es = new EventSource(hubUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
es.onmessage = ({ data }: { data: string }) => onMessage(JSON.parse(data) as T);
|
||||
es.onerror = ({ status }: { status: number }) => status === 401 && onTokenExpired();
|
||||
|
||||
return () => es.close();
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
};
|
||||
54
src/mercure/reducers/mercureInfo.ts
Normal file
54
src/mercure/reducers/mercureInfo.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { ShlinkMercureInfo } from '../../utils/services/types';
|
||||
import { GetState } from '../../container/types';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
|
||||
export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR';
|
||||
export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export interface MercureInfo {
|
||||
token?: string;
|
||||
mercureHubUrl?: string;
|
||||
interval?: number;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
export type GetMercureInfoAction = Action<string> & ShlinkMercureInfo & { interval?: number };
|
||||
|
||||
const initialState: MercureInfo = {
|
||||
loading: true,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<MercureInfo, GetMercureInfoAction>({
|
||||
[GET_MERCURE_INFO_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||
[GET_MERCURE_INFO_ERROR]: (state) => ({ ...state, loading: false, error: true }),
|
||||
[GET_MERCURE_INFO]: (_, action) => ({ ...action, loading: false, error: false }),
|
||||
}, initialState);
|
||||
|
||||
export const loadMercureInfo = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
||||
() => async (dispatch: Dispatch, getState: GetState) => {
|
||||
dispatch({ type: GET_MERCURE_INFO_START });
|
||||
|
||||
const { settings } = getState();
|
||||
const { mercureInfo } = buildShlinkApiClient(getState);
|
||||
|
||||
if (!settings.realTimeUpdates.enabled) {
|
||||
dispatch({ type: GET_MERCURE_INFO_ERROR });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await mercureInfo();
|
||||
|
||||
dispatch<GetMercureInfoAction>({ type: GET_MERCURE_INFO, interval: settings.realTimeUpdates.interval, ...info });
|
||||
} catch (e) {
|
||||
dispatch({ type: GET_MERCURE_INFO_ERROR });
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import Bottle from 'bottlejs';
|
||||
import { loadMercureInfo } from '../reducers/mercureInfo';
|
||||
|
||||
const provideServices = (bottle) => {
|
||||
const provideServices = (bottle: Bottle) => {
|
||||
// Actions
|
||||
bottle.serviceFactory('loadMercureInfo', loadMercureInfo, 'buildShlinkApiClient');
|
||||
};
|
||||
@@ -16,8 +16,9 @@ import tagDeleteReducer from '../tags/reducers/tagDelete';
|
||||
import tagEditReducer from '../tags/reducers/tagEdit';
|
||||
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
||||
import settingsReducer from '../settings/reducers/settings';
|
||||
import { ShlinkState } from '../container/types';
|
||||
|
||||
export default combineReducers({
|
||||
export default combineReducers<ShlinkState>({
|
||||
servers: serversReducer,
|
||||
selectedServer: selectedServerReducer,
|
||||
shortUrlsList: shortUrlsListReducer,
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/promise-function-async, @typescript-eslint/no-misused-promises */
|
||||
|
||||
// In production, we register a service worker to serve assets from local cache.
|
||||
|
||||
// This lets the app load faster on subsequent visits in production, and gives
|
||||
@@ -18,8 +20,8 @@ const isLocalhost = Boolean(
|
||||
|
||||
// 127.0.0.1/8 is considered localhost for IPv4.
|
||||
window.location.hostname.match(
|
||||
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
|
||||
)
|
||||
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/,
|
||||
),
|
||||
);
|
||||
|
||||
export default function register() {
|
||||
@@ -46,7 +48,7 @@ export default function register() {
|
||||
return navigator.serviceWorker.ready.then(() => {
|
||||
console.log(
|
||||
'This web app is being served cache-first by a service ' +
|
||||
'worker. To learn more, visit https://goo.gl/SC7cgQ'
|
||||
'worker. To learn more, visit https://goo.gl/SC7cgQ',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -96,7 +98,7 @@ function checkValidServiceWorker(swUrl) {
|
||||
// Ensure service worker exists, and that we really are getting a JS file.
|
||||
if (
|
||||
response.status === NOT_FOUND_STATUS ||
|
||||
response.headers.get('content-type').indexOf('javascript') === -1
|
||||
response.headers.get('content-type').includes('javascript')
|
||||
) {
|
||||
// No service worker found. Probably a different app. Reload the page.
|
||||
return navigator.serviceWorker.ready.then((registration) =>
|
||||
@@ -110,7 +112,7 @@ function checkValidServiceWorker(swUrl) {
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(
|
||||
'No internet connection found. App is running in offline mode.'
|
||||
'No internet connection found. App is running in offline mode.',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import PropTypes from 'prop-types';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
import './CreateServer.scss';
|
||||
|
||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||
const propTypes = {
|
||||
createServer: PropTypes.func,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
}),
|
||||
resetSelectedServer: PropTypes.func,
|
||||
};
|
||||
|
||||
const CreateServer = (ImportServersBtn, useStateFlagTimeout) => {
|
||||
const CreateServerComp = ({ createServer, history: { push }, resetSelectedServer }) => {
|
||||
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||
const handleSubmit = (serverData) => {
|
||||
const id = uuid();
|
||||
const server = { id, ...serverData };
|
||||
|
||||
createServer(server);
|
||||
push(`/server/${id}/list-short-urls/1`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
resetSelectedServer();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<ServerForm onSubmit={handleSubmit}>
|
||||
<ImportServersBtn onImport={setServersImported} />
|
||||
<button className="btn btn-outline-primary">Create server</button>
|
||||
</ServerForm>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</NoMenuLayout>
|
||||
);
|
||||
};
|
||||
|
||||
CreateServerComp.propTypes = propTypes;
|
||||
|
||||
return CreateServerComp;
|
||||
};
|
||||
|
||||
export default CreateServer;
|
||||
58
src/servers/CreateServer.tsx
Normal file
58
src/servers/CreateServer.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React, { FC } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { RouterProps } from 'react-router';
|
||||
import classNames from 'classnames';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||
import { ServerData, ServerWithId } from './data';
|
||||
import './CreateServer.scss';
|
||||
|
||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||
|
||||
interface CreateServerProps extends RouterProps {
|
||||
createServer: (server: ServerWithId) => void;
|
||||
}
|
||||
|
||||
const Result: FC<{ type: 'success' | 'error' }> = ({ children, type }) => (
|
||||
<div className="row">
|
||||
<div className="col-md-10 offset-md-1">
|
||||
<div
|
||||
className={classNames('p-2 mt-3 text-white text-center', {
|
||||
'bg-main': type === 'success',
|
||||
'bg-danger': type === 'error',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
|
||||
{ createServer, history: { push } }: CreateServerProps,
|
||||
) => {
|
||||
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||
const handleSubmit = (serverData: ServerData) => {
|
||||
const id = uuid();
|
||||
|
||||
createServer({ ...serverData, id });
|
||||
push(`/server/${id}/list-short-urls/1`);
|
||||
};
|
||||
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<ServerForm onSubmit={handleSubmit}>
|
||||
<ImportServersBtn onImport={setServersImported} onImportError={setErrorImporting} />
|
||||
<button className="btn btn-outline-primary">Create server</button>
|
||||
</ServerForm>
|
||||
|
||||
{serversImported && <Result type="success">Servers properly imported. You can now select one from the list :)</Result>}
|
||||
{errorImporting && <Result type="error">The servers could not be imported. Make sure the format is correct.</Result>}
|
||||
</NoMenuLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateServer;
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { serverType } from './prop-types';
|
||||
|
||||
const propTypes = {
|
||||
server: serverType,
|
||||
className: PropTypes.string,
|
||||
textClassName: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
const DeleteServerButton = (DeleteServerModal) => {
|
||||
const DeleteServerButtonComp = ({ server, className, children, textClassName }) => {
|
||||
const [ isModalOpen, , showModal, hideModal ] = useToggle();
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<span className={className} onClick={showModal}>
|
||||
{!children && <FontAwesomeIcon icon={deleteIcon} />}
|
||||
<span className={textClassName}>{children || 'Remove this server'}</span>
|
||||
</span>
|
||||
|
||||
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
DeleteServerButtonComp.propTypes = propTypes;
|
||||
|
||||
return DeleteServerButtonComp;
|
||||
};
|
||||
|
||||
export default DeleteServerButton;
|
||||
31
src/servers/DeleteServerButton.tsx
Normal file
31
src/servers/DeleteServerButton.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { FC } from 'react';
|
||||
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { DeleteServerModalProps } from './DeleteServerModal';
|
||||
import { ServerWithId } from './data';
|
||||
|
||||
export interface DeleteServerButtonProps {
|
||||
server: ServerWithId;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
}
|
||||
|
||||
const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<DeleteServerButtonProps> => (
|
||||
{ server, className, children, textClassName },
|
||||
) => {
|
||||
const [ isModalOpen, , showModal, hideModal ] = useToggle();
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<span className={className} onClick={showModal}>
|
||||
{!children && <FontAwesomeIcon icon={deleteIcon} />}
|
||||
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
||||
</span>
|
||||
|
||||
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteServerButton;
|
||||
@@ -1,19 +1,19 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { serverType } from './prop-types';
|
||||
import { RouterProps } from 'react-router';
|
||||
import { ServerWithId } from './data';
|
||||
|
||||
const propTypes = {
|
||||
toggle: PropTypes.func.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
server: serverType,
|
||||
deleteServer: PropTypes.func,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
}),
|
||||
};
|
||||
export interface DeleteServerModalProps {
|
||||
server: ServerWithId;
|
||||
toggle: () => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) => {
|
||||
interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps {
|
||||
deleteServer: (server: ServerWithId) => void;
|
||||
}
|
||||
|
||||
const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }: DeleteServerModalConnectProps) => {
|
||||
const closeModal = () => {
|
||||
deleteServer(server);
|
||||
toggle();
|
||||
@@ -40,6 +40,4 @@ const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) =>
|
||||
);
|
||||
};
|
||||
|
||||
DeleteServerModal.propTypes = propTypes;
|
||||
|
||||
export default DeleteServerModal;
|
||||
@@ -1,38 +0,0 @@
|
||||
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);
|
||||
};
|
||||
32
src/servers/EditServer.tsx
Normal file
32
src/servers/EditServer.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Button } from 'reactstrap';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
import { withSelectedServer } from './helpers/withSelectedServer';
|
||||
import { isServerWithId, ServerData } from './data';
|
||||
|
||||
interface EditServerProps {
|
||||
editServer: (serverId: string, serverData: ServerData) => void;
|
||||
}
|
||||
|
||||
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
|
||||
{ editServer, selectedServer, history: { push, goBack } },
|
||||
) => {
|
||||
if (!isServerWithId(selectedServer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = (serverData: ServerData) => {
|
||||
editServer(selectedServer.id, serverData);
|
||||
push(`/server/${selectedServer.id}/list-short-urls/1`);
|
||||
};
|
||||
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<ServerForm initialValues={selectedServer} onSubmit={handleSubmit}>
|
||||
<Button outline className="mr-2" onClick={goBack}>Cancel</Button>
|
||||
<Button outline color="primary">Save</Button>
|
||||
</ServerForm>
|
||||
</NoMenuLayout>
|
||||
);
|
||||
}, ServerError);
|
||||
@@ -1,55 +0,0 @@
|
||||
import { isEmpty, values } from 'ramda';
|
||||
import React from 'react';
|
||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { serverType } from './prop-types';
|
||||
|
||||
const propTypes = {
|
||||
servers: PropTypes.object,
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
const ServersDropdown = (serversExporter) => {
|
||||
const ServersDropdownComp = ({ servers, selectedServer }) => {
|
||||
const serversList = values(servers);
|
||||
|
||||
const renderServers = () => {
|
||||
if (isEmpty(serversList)) {
|
||||
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{serversList.map(({ name, id }) => (
|
||||
<DropdownItem
|
||||
key={id}
|
||||
tag={Link}
|
||||
to={`/server/${id}/list-short-urls/1`}
|
||||
active={selectedServer && selectedServer.id === id}
|
||||
>
|
||||
{name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownItem divider />
|
||||
<DropdownItem className="servers-dropdown__export-item" onClick={() => serversExporter.exportServers()}>
|
||||
Export servers
|
||||
</DropdownItem>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<UncontrolledDropdown nav inNavbar>
|
||||
<DropdownToggle nav caret>Servers</DropdownToggle>
|
||||
<DropdownMenu right>{renderServers()}</DropdownMenu>
|
||||
</UncontrolledDropdown>
|
||||
);
|
||||
};
|
||||
|
||||
ServersDropdownComp.propTypes = propTypes;
|
||||
|
||||
return ServersDropdownComp;
|
||||
};
|
||||
|
||||
export default ServersDropdown;
|
||||
49
src/servers/ServersDropdown.tsx
Normal file
49
src/servers/ServersDropdown.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { isEmpty, values } from 'ramda';
|
||||
import React from 'react';
|
||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ServersExporter from './services/ServersExporter';
|
||||
import { isServerWithId, SelectedServer, ServersMap } from './data';
|
||||
|
||||
export interface ServersDropdownProps {
|
||||
servers: ServersMap;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||
const serversList = values(servers);
|
||||
|
||||
const renderServers = () => {
|
||||
if (isEmpty(serversList)) {
|
||||
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{serversList.map(({ name, id }) => (
|
||||
<DropdownItem
|
||||
key={id}
|
||||
tag={Link}
|
||||
to={`/server/${id}/list-short-urls/1`}
|
||||
active={isServerWithId(selectedServer) && selectedServer.id === id}
|
||||
>
|
||||
{name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownItem divider />
|
||||
<DropdownItem className="servers-dropdown__export-item" onClick={async () => serversExporter.exportServers()}>
|
||||
Export servers
|
||||
</DropdownItem>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<UncontrolledDropdown nav inNavbar>
|
||||
<DropdownToggle nav caret>Servers</DropdownToggle>
|
||||
<DropdownMenu right>{renderServers()}</DropdownMenu>
|
||||
</UncontrolledDropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServersDropdown;
|
||||
@@ -1,30 +1,23 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { FC } from 'react';
|
||||
import { ListGroup, ListGroupItem } from 'reactstrap';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { serverType } from './prop-types';
|
||||
import './ServersListGroup.scss';
|
||||
import { ServerWithId } from './data';
|
||||
|
||||
const propTypes = {
|
||||
servers: PropTypes.arrayOf(serverType).isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
interface ServersListGroup {
|
||||
servers: ServerWithId[];
|
||||
}
|
||||
|
||||
const ServerListItem = ({ id, name }) => (
|
||||
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
|
||||
<ListGroupItem tag={Link} to={`/server/${id}/list-short-urls/1`} className="servers-list__server-item">
|
||||
{name}
|
||||
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
|
||||
</ListGroupItem>
|
||||
);
|
||||
|
||||
ServerListItem.propTypes = {
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
};
|
||||
|
||||
const ServersListGroup = ({ servers, children }) => (
|
||||
const ServersListGroup: FC<ServersListGroup> = ({ servers, children }) => (
|
||||
<React.Fragment>
|
||||
<div className="container">
|
||||
<h5>{children}</h5>
|
||||
@@ -37,6 +30,4 @@ const ServersListGroup = ({ servers, children }) => (
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
ServersListGroup.propTypes = propTypes;
|
||||
|
||||
export default ServersListGroup;
|
||||
40
src/servers/data/index.ts
Normal file
40
src/servers/data/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export interface ServerData {
|
||||
name: string;
|
||||
url: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export interface ServerWithId extends ServerData {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ReachableServer extends ServerWithId {
|
||||
version: string;
|
||||
printableVersion: string;
|
||||
}
|
||||
|
||||
export interface NonReachableServer extends ServerWithId {
|
||||
serverNotReachable: true;
|
||||
}
|
||||
|
||||
export interface NotFoundServer {
|
||||
serverNotFound: true;
|
||||
}
|
||||
|
||||
export type RegularServer = ReachableServer | NonReachableServer;
|
||||
|
||||
export type SelectedServer = RegularServer | NotFoundServer | null;
|
||||
|
||||
export type ServersMap = Record<string, ServerWithId>;
|
||||
|
||||
export const hasServerData = (server: SelectedServer | ServerData): server is ServerData =>
|
||||
!!(server as ServerData)?.url && !!(server as ServerData)?.apiKey;
|
||||
|
||||
export const isServerWithId = (server: SelectedServer | ServerWithId): server is ServerWithId =>
|
||||
!!server?.hasOwnProperty('id');
|
||||
|
||||
export const isReachableServer = (server: SelectedServer): server is ReachableServer =>
|
||||
!!server?.hasOwnProperty('printableVersion');
|
||||
|
||||
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
|
||||
!!server?.hasOwnProperty('serverNotFound');
|
||||
@@ -1,30 +0,0 @@
|
||||
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;
|
||||
24
src/servers/helpers/ForServerVersion.tsx
Normal file
24
src/servers/helpers/ForServerVersion.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React, { FC } from 'react';
|
||||
import { versionMatch, Versions } from '../../utils/helpers/version';
|
||||
import { isReachableServer, SelectedServer } from '../data';
|
||||
|
||||
interface ForServerVersionProps extends Versions {
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const ForServerVersion: FC<ForServerVersionProps> = ({ minVersion, maxVersion, selectedServer, children }) => {
|
||||
if (!isReachableServer(selectedServer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { version } = selectedServer;
|
||||
const matchesVersion = versionMatch(version, { maxVersion, minVersion });
|
||||
|
||||
if (!matchesVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
};
|
||||
|
||||
export default ForServerVersion;
|
||||
@@ -1,48 +0,0 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const propTypes = {
|
||||
onImport: PropTypes.func,
|
||||
createServers: PropTypes.func,
|
||||
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
|
||||
};
|
||||
|
||||
// FIXME Replace with typescript: (ServersImporter)
|
||||
const ImportServersBtn = ({ importServersFromFile }) => {
|
||||
const ImportServersBtnComp = ({ createServers, fileRef, onImport = () => {} }) => {
|
||||
const ref = fileRef || useRef();
|
||||
const onChange = ({ target }) =>
|
||||
importServersFromFile(target.files[0])
|
||||
.then(createServers)
|
||||
.then(onImport)
|
||||
.then(() => {
|
||||
// Reset input after processing file
|
||||
target.value = null;
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary mr-2"
|
||||
id="importBtn"
|
||||
onClick={() => ref.current.click()}
|
||||
>
|
||||
Import from file
|
||||
</button>
|
||||
<UncontrolledTooltip placement="top" target="importBtn">
|
||||
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
|
||||
</UncontrolledTooltip>
|
||||
|
||||
<input type="file" accept="text/csv" className="create-server__csv-select" ref={ref} onChange={onChange} />
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
ImportServersBtnComp.propTypes = propTypes;
|
||||
|
||||
return ImportServersBtnComp;
|
||||
};
|
||||
|
||||
export default ImportServersBtn;
|
||||
54
src/servers/helpers/ImportServersBtn.tsx
Normal file
54
src/servers/helpers/ImportServersBtn.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useRef, RefObject, ChangeEvent, MutableRefObject } from 'react';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import ServersImporter from '../services/ServersImporter';
|
||||
import { ServerData } from '../data';
|
||||
|
||||
type Ref<T> = RefObject<T> | MutableRefObject<T>;
|
||||
|
||||
export interface ImportServersBtnProps {
|
||||
onImport?: () => void;
|
||||
onImportError?: () => void;
|
||||
}
|
||||
|
||||
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
||||
createServers: (servers: ServerData[]) => void;
|
||||
fileRef: Ref<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
|
||||
createServers,
|
||||
fileRef,
|
||||
onImport = () => {},
|
||||
onImportError = () => {},
|
||||
}: ImportServersBtnConnectProps) => {
|
||||
const ref = fileRef ?? useRef<HTMLInputElement>();
|
||||
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||
importServersFromFile(target.files?.[0])
|
||||
.then(createServers)
|
||||
.then(onImport)
|
||||
.then(() => {
|
||||
// Reset input after processing file
|
||||
(target as { value: string | null }).value = null;
|
||||
})
|
||||
.catch(onImportError);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary mr-2"
|
||||
id="importBtn"
|
||||
onClick={() => ref.current?.click()}
|
||||
>
|
||||
Import from file
|
||||
</button>
|
||||
<UncontrolledTooltip placement="top" target="importBtn">
|
||||
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
|
||||
</UncontrolledTooltip>
|
||||
|
||||
<input type="file" accept="text/csv" className="create-server__csv-select" ref={ref} onChange={onChange} />
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportServersBtn;
|
||||
@@ -1,50 +0,0 @@
|
||||
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;
|
||||
};
|
||||
45
src/servers/helpers/ServerError.tsx
Normal file
45
src/servers/helpers/ServerError.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Message from '../../utils/Message';
|
||||
import ServersListGroup from '../ServersListGroup';
|
||||
import { DeleteServerButtonProps } from '../DeleteServerButton';
|
||||
import { isServerWithId, SelectedServer, ServersMap } from '../data';
|
||||
import './ServerError.scss';
|
||||
|
||||
interface ServerErrorProps {
|
||||
servers: ServersMap;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
export const ServerError = (DeleteServerButton: FC<DeleteServerButtonProps>): FC<ServerErrorProps> => (
|
||||
{ servers, selectedServer },
|
||||
) => (
|
||||
<div className="server-error__container flex-column">
|
||||
<div className="row w-100 mb-3 mb-md-5">
|
||||
<Message type="error">
|
||||
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
|
||||
{isServerWithId(selectedServer) && (
|
||||
<React.Fragment>
|
||||
<p>Oops! Could not connect to this Shlink server.</p>
|
||||
Make sure you have internet connection, and the server is properly configured and on-line.
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<ServersListGroup servers={Object.values(servers)}>
|
||||
These are the Shlink servers currently configured. Choose one of
|
||||
them or <Link to="/server/create">add a new one</Link>.
|
||||
</ServersListGroup>
|
||||
|
||||
{isServerWithId(selectedServer) && (
|
||||
<div className="container mt-3 mt-md-5">
|
||||
<h5>
|
||||
Alternatively, if you think you may have miss-configured this server, you
|
||||
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or
|
||||
<Link to={`/server/${selectedServer.id}/edit`}>edit it</Link>.
|
||||
</h5>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1,25 +1,18 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { FC, useEffect, useState } from 'react';
|
||||
import { HorizontalFormGroup } from '../../utils/HorizontalFormGroup';
|
||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||
import { ServerData } from '../data';
|
||||
|
||||
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,
|
||||
};
|
||||
interface ServerFormProps {
|
||||
onSubmit: (server: ServerData) => void;
|
||||
initialValues?: ServerData;
|
||||
}
|
||||
|
||||
export const ServerForm = ({ onSubmit, initialValues, children }) => {
|
||||
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children }) => {
|
||||
const [ name, setName ] = useState('');
|
||||
const [ url, setUrl ] = useState('');
|
||||
const [ apiKey, setApiKey ] = useState('');
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit({ name, url, apiKey });
|
||||
};
|
||||
const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey }));
|
||||
|
||||
useEffect(() => {
|
||||
initialValues && setName(initialValues.name);
|
||||
@@ -37,5 +30,3 @@ export const ServerForm = ({ onSubmit, initialValues, children }) => {
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
ServerForm.propTypes = propTypes;
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
};
|
||||
33
src/servers/helpers/withSelectedServer.tsx
Normal file
33
src/servers/helpers/withSelectedServer.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
import Message from '../../utils/Message';
|
||||
import { isNotFoundServer, SelectedServer } from '../data';
|
||||
|
||||
interface WithSelectedServerProps extends RouteComponentProps<{ serverId: string }> {
|
||||
selectServer: (serverId: string) => void;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServerProps & T>, ServerError: FC) {
|
||||
return (props: WithSelectedServerProps & T) => {
|
||||
const { selectServer, selectedServer, match } = props;
|
||||
|
||||
useEffect(() => {
|
||||
selectServer(match.params.serverId);
|
||||
}, [ match.params.serverId ]);
|
||||
|
||||
if (!selectedServer) {
|
||||
return (
|
||||
<div className="row">
|
||||
<Message loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isNotFoundServer(selectedServer)) {
|
||||
return <ServerError />;
|
||||
}
|
||||
|
||||
return <WrappedComponent {...props} />;
|
||||
};
|
||||
}
|
||||
15
src/servers/helpers/withoutSelectedServer.tsx
Normal file
15
src/servers/helpers/withoutSelectedServer.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React, { FC, useEffect } from 'react';
|
||||
|
||||
interface WithoutSelectedServerProps {
|
||||
resetSelectedServer: Function;
|
||||
}
|
||||
|
||||
export function withoutSelectedServer<T = {}>(WrappedComponent: FC<WithoutSelectedServerProps & T>) {
|
||||
return (props: WithoutSelectedServerProps & T) => {
|
||||
useEffect(() => {
|
||||
props.resetSelectedServer();
|
||||
}, []);
|
||||
|
||||
return <WrappedComponent {...props} />;
|
||||
};
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const regularServerType = PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
apiKey: PropTypes.string,
|
||||
version: PropTypes.string,
|
||||
printableVersion: PropTypes.string,
|
||||
serverNotReachable: PropTypes.bool,
|
||||
});
|
||||
|
||||
const notFoundServerType = PropTypes.shape({
|
||||
serverNotFound: PropTypes.bool.isRequired,
|
||||
});
|
||||
|
||||
export const serverType = PropTypes.oneOfType([
|
||||
regularServerType,
|
||||
notFoundServerType,
|
||||
]);
|
||||
@@ -1,19 +1,22 @@
|
||||
import { pipe, prop } from 'ramda';
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { Dispatch } from 'redux';
|
||||
import { homepage } from '../../../package.json';
|
||||
import { ServerData } from '../data';
|
||||
import { createServers } from './servers';
|
||||
|
||||
const responseToServersList = pipe(
|
||||
prop('data'),
|
||||
(value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
prop<any, any>('data'),
|
||||
(data: any): ServerData[] => {
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Value is not an array');
|
||||
}
|
||||
|
||||
return value;
|
||||
return data as ServerData[];
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchServers = ({ get }) => () => async (dispatch) => {
|
||||
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {
|
||||
const remoteList = await get(`${homepage}/servers.json`)
|
||||
.then(responseToServersList)
|
||||
.catch(() => []);
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createAction, handleActions } from 'redux-actions';
|
||||
import { identity, memoizeWith, pipe } from 'ramda';
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
||||
import { SelectedServer } from '../data';
|
||||
import { GetState } from '../../container/types';
|
||||
import { ShlinkHealth } from '../../utils/services/types';
|
||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
||||
@@ -12,22 +17,40 @@ export const MAX_FALLBACK_VERSION = '999.999.999';
|
||||
export const LATEST_VERSION_CONSTRAINT = 'latest';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
const initialState = null;
|
||||
export interface SelectServerAction extends Action<string> {
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const versionToSemVer = pipe(
|
||||
(version) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version,
|
||||
toSemVer(MIN_FALLBACK_VERSION)
|
||||
(version: string) => 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),
|
||||
})));
|
||||
const getServerVersion = memoizeWith(
|
||||
identity,
|
||||
async (_serverId: string, health: () => Promise<ShlinkHealth>) => health().then(({ version }) => ({
|
||||
version: versionToSemVer(version),
|
||||
printableVersion: versionToPrintable(version),
|
||||
})),
|
||||
);
|
||||
|
||||
export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
|
||||
const initialState: SelectedServer = null;
|
||||
|
||||
export const selectServer = (buildShlinkApiClient, loadMercureInfo) => (serverId) => async (
|
||||
dispatch,
|
||||
getState
|
||||
export default buildReducer<SelectedServer, SelectServerAction>({
|
||||
[RESET_SELECTED_SERVER]: () => initialState,
|
||||
[SELECT_SERVER]: (_, { selectedServer }) => selectedServer,
|
||||
}, initialState);
|
||||
|
||||
export const resetSelectedServer = buildActionCreator(RESET_SELECTED_SERVER);
|
||||
|
||||
export const selectServer = (
|
||||
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||
loadMercureInfo: () => Action,
|
||||
) => (
|
||||
serverId: string,
|
||||
) => async (
|
||||
dispatch: Dispatch,
|
||||
getState: GetState,
|
||||
) => {
|
||||
dispatch(resetSelectedServer());
|
||||
dispatch(resetShortUrlParams());
|
||||
@@ -36,7 +59,7 @@ export const selectServer = (buildShlinkApiClient, loadMercureInfo) => (serverId
|
||||
const selectedServer = servers[serverId];
|
||||
|
||||
if (!selectedServer) {
|
||||
dispatch({
|
||||
dispatch<SelectServerAction>({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer: { serverNotFound: true },
|
||||
});
|
||||
@@ -48,7 +71,7 @@ export const selectServer = (buildShlinkApiClient, loadMercureInfo) => (serverId
|
||||
const { health } = buildShlinkApiClient(selectedServer);
|
||||
const { version, printableVersion } = await getServerVersion(serverId, health);
|
||||
|
||||
dispatch({
|
||||
dispatch<SelectServerAction>({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer: {
|
||||
...selectedServer,
|
||||
@@ -58,14 +81,9 @@ export const selectServer = (buildShlinkApiClient, loadMercureInfo) => (serverId
|
||||
});
|
||||
dispatch(loadMercureInfo());
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
dispatch<SelectServerAction>({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer: { ...selectedServer, serverNotReachable: true },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default handleActions({
|
||||
[RESET_SELECTED_SERVER]: () => initialState,
|
||||
[SELECT_SERVER]: (state, { selectedServer }) => selectedServer,
|
||||
}, initialState);
|
||||
@@ -1,35 +0,0 @@
|
||||
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 });
|
||||
51
src/servers/reducers/servers.ts
Normal file
51
src/servers/reducers/servers.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { assoc, dissoc, map, pipe, reduce } from 'ramda';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Action } from 'redux';
|
||||
import { ServerData, ServersMap, ServerWithId } from '../data';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
|
||||
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
||||
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export interface CreateServersAction extends Action<string> {
|
||||
newServers: ServersMap;
|
||||
}
|
||||
|
||||
const initialState: ServersMap = {};
|
||||
|
||||
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
||||
if ((server as ServerWithId).id) {
|
||||
return server as ServerWithId;
|
||||
}
|
||||
|
||||
return assoc('id', uuid(), server);
|
||||
};
|
||||
|
||||
export default buildReducer<ServersMap, CreateServersAction>({
|
||||
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
|
||||
[DELETE_SERVER]: (state, { serverId }: any) => dissoc(serverId, state),
|
||||
[EDIT_SERVER]: (state, { serverId, serverData }: any) => !state[serverId]
|
||||
? state
|
||||
: assoc(serverId, { ...state[serverId], ...serverData }, state),
|
||||
}, initialState);
|
||||
|
||||
const serversListToMap = reduce<ServerWithId, ServersMap>((acc, server) => assoc(server.id, server, acc), {});
|
||||
|
||||
export const createServers = pipe(
|
||||
map(serverWithId),
|
||||
serversListToMap,
|
||||
(newServers: ServersMap) => ({ type: CREATE_SERVERS, newServers }),
|
||||
);
|
||||
|
||||
export const createServer = (server: ServerWithId) => createServers([ server ]);
|
||||
|
||||
export const editServer = (serverId: string, serverData: Partial<ServerData>) => ({
|
||||
type: EDIT_SERVER,
|
||||
serverId,
|
||||
serverData,
|
||||
});
|
||||
|
||||
export const deleteServer = ({ id }: ServerWithId) => ({ type: DELETE_SERVER, serverId: id });
|
||||
@@ -1,6 +1,9 @@
|
||||
import { dissoc, head, keys, values } from 'ramda';
|
||||
import { CsvJson } from 'csvjson';
|
||||
import LocalStorage from '../../utils/services/LocalStorage';
|
||||
import { ServersMap } from '../data';
|
||||
|
||||
const saveCsv = (window, csv) => {
|
||||
const saveCsv = (window: Window, csv: string) => {
|
||||
const { navigator, document } = window;
|
||||
const filename = 'shlink-servers.csv';
|
||||
const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
|
||||
@@ -25,14 +28,14 @@ const saveCsv = (window, csv) => {
|
||||
};
|
||||
|
||||
export default class ServersExporter {
|
||||
constructor(storage, window, csvjson) {
|
||||
this.storage = storage;
|
||||
this.window = window;
|
||||
this.csvjson = csvjson;
|
||||
}
|
||||
public constructor(
|
||||
private readonly storage: LocalStorage,
|
||||
private readonly window: Window,
|
||||
private readonly csvjson: CsvJson,
|
||||
) {}
|
||||
|
||||
exportServers = async () => {
|
||||
const servers = values(this.storage.get('servers') || {}).map(dissoc('id'));
|
||||
public readonly exportServers = async () => {
|
||||
const servers = values(this.storage.get<ServersMap>('servers') || {}).map(dissoc('id'));
|
||||
|
||||
try {
|
||||
const csv = this.csvjson.toCSV(servers, {
|
||||
@@ -1,23 +0,0 @@
|
||||
export default class ServersImporter {
|
||||
constructor(csvjson) {
|
||||
this.csvjson = csvjson;
|
||||
}
|
||||
|
||||
importServersFromFile = (file) => {
|
||||
if (!file || file.type !== 'text/csv') {
|
||||
return Promise.reject('No file provided or file is not a CSV');
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
reader.addEventListener('loadend', (e) => {
|
||||
const content = e.target.result;
|
||||
const servers = this.csvjson.toObject(content);
|
||||
|
||||
resolve(servers);
|
||||
});
|
||||
reader.readAsText(file);
|
||||
});
|
||||
};
|
||||
}
|
||||
26
src/servers/services/ServersImporter.ts
Normal file
26
src/servers/services/ServersImporter.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { CsvJson } from 'csvjson';
|
||||
import { ServerData } from '../data';
|
||||
|
||||
const CSV_MIME_TYPE = 'text/csv';
|
||||
|
||||
export default class ServersImporter {
|
||||
public constructor(private readonly csvjson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
||||
|
||||
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
||||
if (!file || file.type !== CSV_MIME_TYPE) {
|
||||
throw new Error('No file provided or file is not a CSV');
|
||||
}
|
||||
|
||||
const reader = this.fileReaderFactory();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
reader.addEventListener('loadend', (e: ProgressEvent<FileReader>) => {
|
||||
const content = e.target?.result?.toString() ?? '';
|
||||
const servers = this.csvjson.toObject<ServerData>(content);
|
||||
|
||||
resolve(servers);
|
||||
});
|
||||
reader.readAsText(file);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import csvjson from 'csvjson';
|
||||
import Bottle, { Decorator } from 'bottlejs';
|
||||
import CreateServer from '../CreateServer';
|
||||
import ServersDropdown from '../ServersDropdown';
|
||||
import DeleteServerModal from '../DeleteServerModal';
|
||||
@@ -10,12 +11,15 @@ import { createServer, createServers, deleteServer, editServer } from '../reduce
|
||||
import { fetchServers } from '../reducers/remoteServers';
|
||||
import ForServerVersion from '../helpers/ForServerVersion';
|
||||
import { ServerError } from '../helpers/ServerError';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
|
||||
import ServersImporter from './ServersImporter';
|
||||
import ServersExporter from './ServersExporter';
|
||||
|
||||
const provideServices = (bottle, connect, withRouter) => {
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
|
||||
bottle.decorator('CreateServer', withoutSelectedServer);
|
||||
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
|
||||
@@ -41,7 +45,8 @@ const provideServices = (bottle, connect, withRouter) => {
|
||||
|
||||
// Services
|
||||
bottle.constant('csvjson', csvjson);
|
||||
bottle.service('ServersImporter', ServersImporter, 'csvjson');
|
||||
bottle.constant('fileReaderFactory', () => new FileReader());
|
||||
bottle.service('ServersImporter', ServersImporter, 'csvjson', 'fileReaderFactory');
|
||||
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'csvjson');
|
||||
|
||||
// Actions
|
||||
@@ -1,25 +0,0 @@
|
||||
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;
|
||||
53
src/settings/RealTimeUpdates.tsx
Normal file
53
src/settings/RealTimeUpdates.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { Card, CardBody, CardHeader, FormGroup, Input } from 'reactstrap';
|
||||
import classNames from 'classnames';
|
||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||
import { Settings } from './reducers/settings';
|
||||
|
||||
interface RealTimeUpdatesProps {
|
||||
settings: Settings;
|
||||
toggleRealTimeUpdates: (enabled: boolean) => void;
|
||||
setRealTimeUpdatesInterval: (interval: number) => void;
|
||||
}
|
||||
|
||||
const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
|
||||
|
||||
const RealTimeUpdates = (
|
||||
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
|
||||
) => (
|
||||
<Card>
|
||||
<CardHeader>Real-time updates</CardHeader>
|
||||
<CardBody>
|
||||
<FormGroup>
|
||||
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
|
||||
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
|
||||
</ToggleSwitch>
|
||||
</FormGroup>
|
||||
<FormGroup className="mb-0">
|
||||
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
|
||||
Real-time updates frequency (in minutes):
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Immediate"
|
||||
disabled={!realTimeUpdates.enabled}
|
||||
value={intervalValue(realTimeUpdates.interval)}
|
||||
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
|
||||
/>
|
||||
{realTimeUpdates.enabled && (
|
||||
<small className="form-text text-muted">
|
||||
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
|
||||
<span>
|
||||
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
|
||||
</span>
|
||||
)}
|
||||
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
|
||||
</small>
|
||||
)}
|
||||
</FormGroup>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default RealTimeUpdates;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
|
||||
const Settings = (RealTimeUpdates) => () => (
|
||||
const Settings = (RealTimeUpdates: FC) => () => (
|
||||
<NoMenuLayout>
|
||||
<RealTimeUpdates />
|
||||
</NoMenuLayout>
|
||||
@@ -1,25 +0,0 @@
|
||||
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 },
|
||||
});
|
||||
39
src/settings/reducers/settings.ts
Normal file
39
src/settings/reducers/settings.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Action } from 'redux';
|
||||
import { mergeDeepRight } from 'ramda';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { RecursivePartial } from '../../utils/utils';
|
||||
|
||||
export const SET_REAL_TIME_UPDATES = 'shlink/realTimeUpdates/SET_REAL_TIME_UPDATES';
|
||||
|
||||
interface RealTimeUpdates {
|
||||
enabled: boolean;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
realTimeUpdates: RealTimeUpdates;
|
||||
}
|
||||
|
||||
const initialState: Settings = {
|
||||
realTimeUpdates: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
type SettingsAction = Action & Settings;
|
||||
|
||||
type PartialSettingsAction = Action & RecursivePartial<Settings>;
|
||||
|
||||
export default buildReducer<Settings, SettingsAction>({
|
||||
[SET_REAL_TIME_UPDATES]: (state, { realTimeUpdates }) => mergeDeepRight(state, { realTimeUpdates }),
|
||||
}, initialState);
|
||||
|
||||
export const toggleRealTimeUpdates = (enabled: boolean): PartialSettingsAction => ({
|
||||
type: SET_REAL_TIME_UPDATES,
|
||||
realTimeUpdates: { enabled },
|
||||
});
|
||||
|
||||
export const setRealTimeUpdatesInterval = (interval: number): PartialSettingsAction => ({
|
||||
type: SET_REAL_TIME_UPDATES,
|
||||
realTimeUpdates: { interval },
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
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;
|
||||
26
src/settings/services/provideServices.ts
Normal file
26
src/settings/services/provideServices.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import Bottle from 'bottlejs';
|
||||
import RealTimeUpdates from '../RealTimeUpdates';
|
||||
import Settings from '../Settings';
|
||||
import { setRealTimeUpdatesInterval, toggleRealTimeUpdates } from '../reducers/settings';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates');
|
||||
bottle.decorator('Settings', withoutSelectedServer);
|
||||
bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ]));
|
||||
|
||||
// Services
|
||||
bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates);
|
||||
bottle.decorator(
|
||||
'RealTimeUpdates',
|
||||
connect([ 'settings' ], [ 'toggleRealTimeUpdates', 'setRealTimeUpdatesInterval' ]),
|
||||
);
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
|
||||
bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval);
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
@@ -1,173 +0,0 @@
|
||||
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { isEmpty, isNil, pipe, replace, trim } from 'ramda';
|
||||
import React, { useState } from 'react';
|
||||
import { Collapse, FormGroup, Input } from 'reactstrap';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import DateInput from '../utils/DateInput';
|
||||
import Checkbox from '../utils/Checkbox';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import { versionMatch } from '../utils/helpers/version';
|
||||
import { hasValue } from '../utils/utils';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { createShortUrlResultType } from './reducers/shortUrlCreation';
|
||||
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
||||
|
||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||
const formatDate = (date) => isNil(date) ? date : date.format();
|
||||
|
||||
const propTypes = {
|
||||
createShortUrl: PropTypes.func,
|
||||
shortUrlCreationResult: createShortUrlResultType,
|
||||
resetCreateShortUrl: PropTypes.func,
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
longUrl: '',
|
||||
tags: [],
|
||||
customSlug: '',
|
||||
shortCodeLength: '',
|
||||
domain: '',
|
||||
validSince: undefined,
|
||||
validUntil: undefined,
|
||||
maxVisits: '',
|
||||
findIfExists: false,
|
||||
};
|
||||
|
||||
const CreateShortUrl = (TagsSelector, CreateShortUrlResult, ForServerVersion) => {
|
||||
const CreateShortUrlComp = ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }) => {
|
||||
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
|
||||
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
|
||||
|
||||
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 = {}) => (
|
||||
<FormGroup>
|
||||
<Input
|
||||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
value={shortUrlCreation[id]}
|
||||
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, [id]: e.target.value })}
|
||||
{...props}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
const renderDateInput = (id, placeholder, props = {}) => (
|
||||
<div className="form-group">
|
||||
<DateInput
|
||||
selected={shortUrlCreation[id]}
|
||||
placeholderText={placeholder}
|
||||
isClearable
|
||||
onChange={(date) => setShortUrlCreation({ ...shortUrlCreation, [id]: date })}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const currentServerVersion = selectedServer && selectedServer.version;
|
||||
const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
|
||||
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
|
||||
|
||||
return (
|
||||
<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">
|
||||
<TagsSelector tags={shortUrlCreation.tags} onChange={changeTags} />
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('customSlug', 'Custom slug')}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
||||
min: 4,
|
||||
disabled: disableShortCodeLength || hasValue(shortUrlCreation.customSlug),
|
||||
...disableShortCodeLength && {
|
||||
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('domain', 'Domain', 'text', {
|
||||
disabled: disableDomain,
|
||||
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil })}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ForServerVersion minVersion="1.16.0">
|
||||
<div className="mb-4 text-right">
|
||||
<Checkbox
|
||||
className="mr-2"
|
||||
checked={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;
|
||||
178
src/short-urls/CreateShortUrl.tsx
Normal file
178
src/short-urls/CreateShortUrl.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { isEmpty, pipe, replace, trim } from 'ramda';
|
||||
import React, { FC, useState } from 'react';
|
||||
import { Collapse, FormGroup, Input } from 'reactstrap';
|
||||
import { InputType } from 'reactstrap/lib/Input';
|
||||
import * as m from 'moment';
|
||||
import DateInput, { DateInputProps } from '../utils/DateInput';
|
||||
import Checkbox from '../utils/Checkbox';
|
||||
import { versionMatch, Versions } from '../utils/helpers/version';
|
||||
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||
import { formatIsoDate } from '../utils/helpers/date';
|
||||
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||
import { ShortUrlData } from './data';
|
||||
import { ShortUrlCreation } from './reducers/shortUrlCreation';
|
||||
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
||||
import { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
|
||||
|
||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||
|
||||
interface CreateShortUrlProps {
|
||||
shortUrlCreationResult: ShortUrlCreation;
|
||||
selectedServer: SelectedServer;
|
||||
createShortUrl: Function;
|
||||
resetCreateShortUrl: () => void;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlData = {
|
||||
longUrl: '',
|
||||
tags: [],
|
||||
customSlug: '',
|
||||
shortCodeLength: undefined,
|
||||
domain: '',
|
||||
validSince: undefined,
|
||||
validUntil: undefined,
|
||||
maxVisits: undefined,
|
||||
findIfExists: false,
|
||||
};
|
||||
|
||||
type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits';
|
||||
type DateFields = 'validSince' | 'validUntil';
|
||||
|
||||
const CreateShortUrl = (
|
||||
TagsSelector: FC<TagsSelectorProps>,
|
||||
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
|
||||
ForServerVersion: FC<Versions>,
|
||||
) => ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }: CreateShortUrlProps) => {
|
||||
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
|
||||
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
|
||||
|
||||
const changeTags = (tags: string[]) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
|
||||
const reset = () => setShortUrlCreation(initialState);
|
||||
const save = handleEventPreventingDefault(() => {
|
||||
const shortUrlData = {
|
||||
...shortUrlCreation,
|
||||
validSince: formatIsoDate(shortUrlCreation.validSince),
|
||||
validUntil: formatIsoDate(shortUrlCreation.validUntil),
|
||||
};
|
||||
|
||||
createShortUrl(shortUrlData).then(reset).catch(() => {});
|
||||
});
|
||||
const renderOptionalInput = (id: NonDateFields, placeholder: string, type: InputType = 'text', props = {}) => (
|
||||
<FormGroup>
|
||||
<Input
|
||||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
value={shortUrlCreation[id]}
|
||||
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, [id]: e.target.value })}
|
||||
{...props}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
|
||||
<div className="form-group">
|
||||
<DateInput
|
||||
selected={shortUrlCreation[id] as m.Moment | null}
|
||||
placeholderText={placeholder}
|
||||
isClearable
|
||||
onChange={(date) => setShortUrlCreation({ ...shortUrlCreation, [id]: date })}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const currentServerVersion = isReachableServer(selectedServer) ? selectedServer.version : '';
|
||||
const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
|
||||
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
|
||||
|
||||
return (
|
||||
<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">
|
||||
<TagsSelector tags={shortUrlCreation.tags ?? []} onChange={changeTags} />
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
|
||||
disabled: hasValue(shortUrlCreation.shortCodeLength),
|
||||
})}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
||||
min: 4,
|
||||
disabled: disableShortCodeLength || hasValue(shortUrlCreation.customSlug),
|
||||
...disableShortCodeLength && {
|
||||
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('domain', 'Domain', 'text', {
|
||||
disabled: disableDomain,
|
||||
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil as m.Moment | undefined })}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince as m.Moment | undefined })}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateShortUrl;
|
||||
@@ -1,20 +1,17 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
|
||||
import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination';
|
||||
import { ShlinkPaginator } from '../utils/services/types';
|
||||
import './Paginator.scss';
|
||||
|
||||
const propTypes = {
|
||||
serverId: PropTypes.string.isRequired,
|
||||
paginator: PropTypes.shape({
|
||||
currentPage: PropTypes.number,
|
||||
pagesCount: PropTypes.number,
|
||||
}),
|
||||
};
|
||||
interface PaginatorProps {
|
||||
paginator?: ShlinkPaginator;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
const Paginator = ({ paginator = {}, serverId }) => {
|
||||
const { currentPage, pagesCount = 0 } = paginator;
|
||||
const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
||||
const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
|
||||
|
||||
if (pagesCount <= 1) {
|
||||
return null;
|
||||
@@ -24,14 +21,14 @@ const Paginator = ({ paginator = {}, serverId }) => {
|
||||
progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
|
||||
<PaginationItem
|
||||
key={keyForPage(pageNumber, index)}
|
||||
disabled={isPageDisabled(pageNumber)}
|
||||
disabled={pageIsEllipsis(pageNumber)}
|
||||
active={currentPage === pageNumber}
|
||||
>
|
||||
<PaginationLink
|
||||
tag={Link}
|
||||
to={`/server/${serverId}/list-short-urls/${pageNumber}`}
|
||||
>
|
||||
{pageNumber}
|
||||
{prettifyPageNumber(pageNumber)}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
));
|
||||
@@ -57,6 +54,4 @@ const Paginator = ({ paginator = {}, serverId }) => {
|
||||
);
|
||||
};
|
||||
|
||||
Paginator.propTypes = propTypes;
|
||||
|
||||
export default Paginator;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user