mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-03 14:21:49 +00:00
Compare commits
172 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9cef8a029 | ||
|
|
e577eb48d6 | ||
|
|
d08a69954a | ||
|
|
fe81bfccef | ||
|
|
4869435aca | ||
|
|
0822cebb10 | ||
|
|
01a18f2342 | ||
|
|
a22274f382 | ||
|
|
c0098ac7fd | ||
|
|
ba5a99dc2a | ||
|
|
1927ad2d3a | ||
|
|
0356a0204d | ||
|
|
3bf64bee1e | ||
|
|
da484374a1 | ||
|
|
7b9447b717 | ||
|
|
e583eb2759 | ||
|
|
93b4de60f6 | ||
|
|
16f4f7eac8 | ||
|
|
90d4fe72db | ||
|
|
e1298cfa81 | ||
|
|
6be3a1223f | ||
|
|
81d24432a9 | ||
|
|
1d193f1187 | ||
|
|
c56994c813 | ||
|
|
44862073bb | ||
|
|
9eb9182c21 | ||
|
|
b2abfd543e | ||
|
|
8c6eaf2f1d | ||
|
|
811544d7df | ||
|
|
9fdfdf865e | ||
|
|
6a354c277c | ||
|
|
89f6c6c283 | ||
|
|
d534a4e441 | ||
|
|
4c3772d5c8 | ||
|
|
ee95d5a1b7 | ||
|
|
51379eb2a0 | ||
|
|
f69f791790 | ||
|
|
54b1ab12cd | ||
|
|
18d417e78c | ||
|
|
7a48a06442 | ||
|
|
195aaa8be6 | ||
|
|
94d2f3167b | ||
|
|
344f5e9b0d | ||
|
|
b211a29fc5 | ||
|
|
c25355c531 | ||
|
|
5cf0c86a14 | ||
|
|
852e791c80 | ||
|
|
f5d03ed3a2 | ||
|
|
4642e07fd3 | ||
|
|
83221c1066 | ||
|
|
214b952e84 | ||
|
|
42adbb3739 | ||
|
|
9e63c463ca | ||
|
|
260a6c4940 | ||
|
|
fa949cde12 | ||
|
|
23da0328ec | ||
|
|
7da634e772 | ||
|
|
79f7459d77 | ||
|
|
4002392b12 | ||
|
|
e9e53bb69b | ||
|
|
623deec973 | ||
|
|
3453d4ffd5 | ||
|
|
f9ef7eccf8 | ||
|
|
3cdcffaac3 | ||
|
|
0f23cdcd21 | ||
|
|
9dc6c756f2 | ||
|
|
0491694839 | ||
|
|
f1f3c3f98b | ||
|
|
ec3ad8412c | ||
|
|
d39512732a | ||
|
|
95abf4f898 | ||
|
|
61a1087d91 | ||
|
|
3f245a757e | ||
|
|
4e236a80de | ||
|
|
288f6e2cf8 | ||
|
|
9b6d4a4d97 | ||
|
|
f2a8865679 | ||
|
|
017db18e70 | ||
|
|
19c4a61524 | ||
|
|
f01c9bd5c8 | ||
|
|
2a5fa54ae1 | ||
|
|
7a1b6367a8 | ||
|
|
058860737e | ||
|
|
20f2fd1080 | ||
|
|
16ce1d24af | ||
|
|
a51db38749 | ||
|
|
6090f97347 | ||
|
|
c74355e363 | ||
|
|
a013d40bf1 | ||
|
|
7f7473c348 | ||
|
|
df6f1b984f | ||
|
|
b9905c8bf4 | ||
|
|
32957835b3 | ||
|
|
2efc5feb3f | ||
|
|
526fa14dce | ||
|
|
4d969b994e | ||
|
|
d62edb2249 | ||
|
|
bc82e7e7fd | ||
|
|
1e460d3ef7 | ||
|
|
143a05cab1 | ||
|
|
bf1b59c0d8 | ||
|
|
5ab38027bf | ||
|
|
3e6aee47e5 | ||
|
|
60282281a3 | ||
|
|
2017ee7456 | ||
|
|
e60d241fcf | ||
|
|
43af6fdaba | ||
|
|
f359a16004 | ||
|
|
1b413fb0b7 | ||
|
|
20a9259109 | ||
|
|
8d5f7e942d | ||
|
|
17d5c4327b | ||
|
|
9b30a82a79 | ||
|
|
a0ec3c0293 | ||
|
|
d9e39eee2b | ||
|
|
032e9c53f3 | ||
|
|
dba0ac6442 | ||
|
|
920effb4c6 | ||
|
|
bd6e455cd6 | ||
|
|
b9fc906537 | ||
|
|
1415f196bb | ||
|
|
8f7e356e54 | ||
|
|
0ed88079ad | ||
|
|
5182f9d147 | ||
|
|
4e1579832e | ||
|
|
ff48c0cd45 | ||
|
|
02c7125236 | ||
|
|
dc397d4b82 | ||
|
|
2a206f11b9 | ||
|
|
369fcf2f6a | ||
|
|
983e4db3b1 | ||
|
|
2a7c2474cd | ||
|
|
c890124e67 | ||
|
|
3e21cccb14 | ||
|
|
dafebc3df9 | ||
|
|
6619e7cdb6 | ||
|
|
c54f314424 | ||
|
|
4964f28169 | ||
|
|
dead22c332 | ||
|
|
aba65346b4 | ||
|
|
4621246cec | ||
|
|
f83280068b | ||
|
|
0671fa6567 | ||
|
|
5c80e853c6 | ||
|
|
6c90d7072f | ||
|
|
18bccab27a | ||
|
|
b9213952d3 | ||
|
|
f1ae68a300 | ||
|
|
3f0409f25a | ||
|
|
6f568a16bf | ||
|
|
39ae3b4762 | ||
|
|
14e31ed2c3 | ||
|
|
ff1fb0dd12 | ||
|
|
2e6a35181d | ||
|
|
22cca598ca | ||
|
|
de906bf370 | ||
|
|
498594c31b | ||
|
|
cfbd246cfc | ||
|
|
3f91c556e4 | ||
|
|
4d1622607c | ||
|
|
eacdee293c | ||
|
|
f4b115cffd | ||
|
|
7dcd623513 | ||
|
|
8b00d1aaae | ||
|
|
facfd33e96 | ||
|
|
a841dc7531 | ||
|
|
28ebd55b69 | ||
|
|
3eade5a0c0 | ||
|
|
caf74cd87b | ||
|
|
049510f513 | ||
|
|
b151b7eedb | ||
|
|
4e22e9c092 |
@@ -23,6 +23,7 @@
|
|||||||
}],
|
}],
|
||||||
"no-mixed-operators": "off",
|
"no-mixed-operators": "off",
|
||||||
"react/display-name": "off",
|
"react/display-name": "off",
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
"@typescript-eslint/require-array-sort-compare": "off"
|
"@typescript-eslint/require-array-sort-compare": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
.github/workflows/ci.yml
vendored
Normal file
59
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
name: Continuous integration
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request: null
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
continue-on-error: true
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: Use node.js 14.15
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: 14.15
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run lint
|
||||||
|
|
||||||
|
unit-tests:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: Use node.js 14.15
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: 14.15
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run test:ci
|
||||||
|
- name: Publish coverage
|
||||||
|
uses: codecov/codecov-action@v1
|
||||||
|
with:
|
||||||
|
file: ./coverage/clover.xml
|
||||||
|
|
||||||
|
mutation-tests:
|
||||||
|
continue-on-error: true
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # needed so that the main branch is also fetched
|
||||||
|
- name: Use node.js 14.15
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: 14.15
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run mutate -- --mutate=$(git diff origin/main --name-only | grep -E 'src\/(.*).(ts|tsx)$' | paste -sd ",")
|
||||||
|
|
||||||
|
build-docker-image:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- run: docker build -t shlink-web-client:test .
|
||||||
16
.github/workflows/docker-image-build.yml
vendored
16
.github/workflows/docker-image-build.yml
vendored
@@ -9,16 +9,20 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-20.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
- name: Install buildx
|
- name: Set up QEMU
|
||||||
id: buildx
|
uses: docker/setup-qemu-action@v1
|
||||||
uses: crazy-max/ghaction-docker-buildx@v1
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
with:
|
with:
|
||||||
buildx-version: latest
|
version: latest
|
||||||
- name: Login to docker hub
|
- name: Login to docker hub
|
||||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||||
- name: Build the image
|
- name: Build the image
|
||||||
run: bash ./scripts/docker/build
|
run: bash ./scripts/docker/build
|
||||||
|
|||||||
28
.github/workflows/publish-release.yml
vendored
Normal file
28
.github/workflows/publish-release.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
name: Publish release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: Use node.js 14.15
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: 14.15
|
||||||
|
- name: Generate release assets
|
||||||
|
run: npm ci && npm run build ${GITHUB_REF#refs/tags/v}
|
||||||
|
- name: Publish release with assets
|
||||||
|
uses: docker://antonyurchenko/git-release:latest
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
ALLOW_TAG_PREFIX: "true"
|
||||||
|
ALLOW_EMPTY_CHANGELOG: "true"
|
||||||
|
with:
|
||||||
|
args: |
|
||||||
|
dist/shlink-web-client_*_dist.zip
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
build:
|
|
||||||
environment:
|
|
||||||
node: v12.14.1
|
|
||||||
tools:
|
|
||||||
external_code_coverage:
|
|
||||||
timeout: 1200
|
|
||||||
52
.travis.yml
52
.travis.yml
@@ -1,52 +0,0 @@
|
|||||||
dist: bionic
|
|
||||||
|
|
||||||
language: node_js
|
|
||||||
|
|
||||||
branches:
|
|
||||||
only:
|
|
||||||
- /.*/
|
|
||||||
|
|
||||||
cache:
|
|
||||||
directories:
|
|
||||||
- node_modules
|
|
||||||
|
|
||||||
node_js:
|
|
||||||
- '12.16.3'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
fast_finish: true
|
|
||||||
allow_failures:
|
|
||||||
- name: 'Mutation tests'
|
|
||||||
include:
|
|
||||||
|
|
||||||
- name: 'Lint'
|
|
||||||
install: npm ci
|
|
||||||
script: npm run lint
|
|
||||||
|
|
||||||
- name: 'Unit tests'
|
|
||||||
install: npm ci
|
|
||||||
script: npm run test:ci
|
|
||||||
after_success:
|
|
||||||
- node_modules/.bin/ocular coverage/clover.xml
|
|
||||||
|
|
||||||
- name: 'Mutation tests'
|
|
||||||
install: npm ci
|
|
||||||
before_script:
|
|
||||||
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
|
|
||||||
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/main} --name-only | grep -E 'src\/(.*).(ts|tsx)$' | paste -sd ",")
|
|
||||||
script: npm run mutate:ci
|
|
||||||
|
|
||||||
- name: 'Build docker image'
|
|
||||||
services:
|
|
||||||
- docker
|
|
||||||
script: docker build -t shlink-web-client:test .
|
|
||||||
|
|
||||||
- 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
|
|
||||||
502
CHANGELOG.md
502
CHANGELOG.md
@@ -4,10 +4,100 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
## 2.6.0 - 2020-09-20
|
## [3.0.1] - 2020-12-30
|
||||||
|
### Added
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
#### Added
|
### Changed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* [#364](https://github.com/shlinkio/shlink-web-client/issues/364) Fixed all dropdowns so that they are consistently styled.
|
||||||
|
* [#366](https://github.com/shlinkio/shlink-web-client/issues/366) Fixed text in visits menu jumping to next line in some tablet resolutions.
|
||||||
|
* [#367](https://github.com/shlinkio/shlink-web-client/issues/367) Removed conflicting overflow in visits table for mobile devices.
|
||||||
|
* [#365](https://github.com/shlinkio/shlink-web-client/issues/365) Fixed weird rendering of short URLs list in tablets.
|
||||||
|
* [#372](https://github.com/shlinkio/shlink-web-client/issues/372) Fixed importing servers in Android devices.
|
||||||
|
|
||||||
|
|
||||||
|
## [3.0.0] - 2020-12-22
|
||||||
|
### Added
|
||||||
|
* [#340](https://github.com/shlinkio/shlink-web-client/issues/340) Added new "overview" page, showing basic information of the active server.
|
||||||
|
|
||||||
|
As a side effect, it also introduces improvements in the "create short URL" page, grouping components by context and explaining what they are for.
|
||||||
|
|
||||||
|
* [#309](https://github.com/shlinkio/shlink-web-client/issues/309) Added new domain selector component in create URL form which allows selecting from previously used domains or set a new one.
|
||||||
|
* [#315](https://github.com/shlinkio/shlink-web-client/issues/315) Now you can tell if you want to validate the long URL when using Shlink >=2.4.
|
||||||
|
* [#285](https://github.com/shlinkio/shlink-web-client/issues/285) Improved visits section:
|
||||||
|
|
||||||
|
* Charts are now grouped in tabs, so that only one part of the components is rendered at a time.
|
||||||
|
* Amount of highlighted visits is now displayed.
|
||||||
|
* Date filtering can be now selected through relative times (last 7 days, last 30 days, etc) or absolute dates using date pickers.
|
||||||
|
* Only the visits for last 30 days are loaded by default. You can change that at any moment if required.
|
||||||
|
|
||||||
|
* [#355](https://github.com/shlinkio/shlink-web-client/issues/355) Improved home page, fixing also its scrolling behavior for mobile devices.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* [#267](https://github.com/shlinkio/shlink-web-client/issues/267) Added some subtle but important improvements on UI/UX.
|
||||||
|
* [#352](https://github.com/shlinkio/shlink-web-client/issues/352) Moved from Scrutinizer to Codecov as the code coverage backend.
|
||||||
|
* [#217](https://github.com/shlinkio/shlink-web-client/issues/217) Improved how messages are displayed, by centralizing it in the `Message` and `Result` components.
|
||||||
|
* [#219](https://github.com/shlinkio/shlink-web-client/issues/219) Improved error messages when something fails while interacting with Shlink's API.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* [#344](https://github.com/shlinkio/shlink-web-client/issues/344) Dropped support for Shlink v1.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
|
||||||
|
## [2.6.2] - 2020-11-14
|
||||||
|
### Added
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* [#325](https://github.com/shlinkio/shlink-web-client/issues/325) and [#294](https://github.com/shlinkio/shlink-web-client/issues/294) Updated all dependencies, including React 17, Typescript 4, react-datepicker 3 and Stryker 4.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* [#334](https://github.com/shlinkio/shlink-web-client/issues/334) Fixed color-picker making the app crash when closing the modal without closing the color-picker, and then trying to open the modal again.
|
||||||
|
* [#333](https://github.com/shlinkio/shlink-web-client/issues/333) Fixed visits getting accumulated every time the visits page is opened.
|
||||||
|
|
||||||
|
|
||||||
|
## [2.6.1] - 2020-10-31
|
||||||
|
### Added
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* [#292](https://github.com/shlinkio/shlink-web-client/issues/292) Improved a bit how caching works by removing the service worker and adding proper HTTP caching config on nginx inside docker image.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* [#316](https://github.com/shlinkio/shlink-web-client/issues/316) Fixed manifest.json file not getting downloaded after passing credentials when the app is protected with basic auth.
|
||||||
|
* [#311](https://github.com/shlinkio/shlink-web-client/issues/311) Fixed datepicker showing below other components.
|
||||||
|
* [#306](https://github.com/shlinkio/shlink-web-client/issues/306) Fixed multi-arch docker builds by replacing node-sass with dart-sass.
|
||||||
|
* [#328](https://github.com/shlinkio/shlink-web-client/issues/328) Fixed toggle switches getting broken in mobile resolutions.
|
||||||
|
|
||||||
|
|
||||||
|
## [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.
|
* [#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.
|
* [#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.
|
* [#250](https://github.com/shlinkio/shlink-web-client/issues/250) Added support to group real time updates in fixed intervals.
|
||||||
@@ -18,57 +108,45 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
|
|
||||||
* [#277](https://github.com/shlinkio/shlink-web-client/issues/277) Added highlighting capabilities to the visits line chart.
|
* [#277](https://github.com/shlinkio/shlink-web-client/issues/277) Added highlighting capabilities to the visits line chart.
|
||||||
|
|
||||||
#### Changed
|
### 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.
|
* [#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.
|
* [#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.
|
* [#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.
|
* [#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.
|
* [#305](https://github.com/shlinkio/shlink-web-client/issues/305) Split travis build so that every step is run in a parallel job.
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* [#295](https://github.com/shlinkio/shlink-web-client/issues/295) Fixed custom slug field not being disabled when selecting a short code length.
|
* [#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.
|
* [#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
|
## [2.5.1] - 2020-06-06
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* [#254](https://github.com/shlinkio/shlink-web-client/issues/254) Reduced duplication on code to handle mercure topics binding.
|
* [#254](https://github.com/shlinkio/shlink-web-client/issues/254) Reduced duplication on code to handle mercure topics binding.
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### 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.
|
* [#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.
|
* [#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.
|
* [#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
|
## [2.5.0] - 2020-05-31
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* [#148](https://github.com/shlinkio/shlink-web-client/issues/148) Added support for real-time updates when consuming a Shlink version that is integrated with a mercure hub server.
|
* [#148](https://github.com/shlinkio/shlink-web-client/issues/148) Added support for real-time updates when consuming a Shlink version that is integrated with a mercure hub server.
|
||||||
|
|
||||||
The integration is transparent. When a server is opened, shlink-web-client will try to get the mercure info from it.
|
The integration is transparent. When a server is opened, shlink-web-client will try to get the mercure info from it.
|
||||||
@@ -88,28 +166,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
|
|
||||||
* [#149](https://github.com/shlinkio/shlink-web-client/issues/149) and [#198](https://github.com/shlinkio/shlink-web-client/issues/198) Added new line chart to visits and tags stats which displays amount of visits during selected time period, grouped by month, week, day or hour.
|
* [#149](https://github.com/shlinkio/shlink-web-client/issues/149) and [#198](https://github.com/shlinkio/shlink-web-client/issues/198) Added new line chart to visits and tags stats which displays amount of visits during selected time period, grouped by month, week, day or hour.
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* [#218](https://github.com/shlinkio/shlink-web-client/issues/218) Added back button to sections not displayed in left menu.
|
* [#218](https://github.com/shlinkio/shlink-web-client/issues/218) Added back button to sections not displayed in left menu.
|
||||||
* [#255](https://github.com/shlinkio/shlink-web-client/issues/255) Improved how servers and settings are persisted in the local storage.
|
* [#255](https://github.com/shlinkio/shlink-web-client/issues/255) Improved how servers and settings are persisted in the local storage.
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* [#262](https://github.com/shlinkio/shlink-web-client/issues/262) Fixed charts displaying decimal numbers, when visits are absolute and that makes no sense.
|
* [#262](https://github.com/shlinkio/shlink-web-client/issues/262) Fixed charts displaying decimal numbers, when visits are absolute and that makes no sense.
|
||||||
|
|
||||||
|
|
||||||
## 2.4.0 - 2020-04-10
|
## [2.4.0] - 2020-04-10
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* [#199](https://github.com/shlinkio/shlink-web-client/issues/199) Added table to visits page which displays the information in a paginated, sortable and filterable list.
|
* [#199](https://github.com/shlinkio/shlink-web-client/issues/199) Added table to visits page which displays the information in a paginated, sortable and filterable list.
|
||||||
|
|
||||||
It also supports selecting multiple visits in the table which makes the corresponding data to be highlighted in the visits charts.
|
It also supports selecting multiple visits in the table which makes the corresponding data to be highlighted in the visits charts.
|
||||||
@@ -124,432 +196,330 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
* [#234](https://github.com/shlinkio/shlink-web-client/issues/234) Allowed short code length to be edited on any new short URL when using Shlink 2.1 or higher.
|
* [#234](https://github.com/shlinkio/shlink-web-client/issues/234) Allowed short code length to be edited on any new short URL when using Shlink 2.1 or higher.
|
||||||
* [#235](https://github.com/shlinkio/shlink-web-client/issues/235) Allowed editing the long URL for any existing short URL when suing Shlink 2.1 or higher.
|
* [#235](https://github.com/shlinkio/shlink-web-client/issues/235) Allowed editing the long URL for any existing short URL when suing Shlink 2.1 or higher.
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* [#205](https://github.com/shlinkio/shlink-web-client/issues/205) Replaced `jest-each` package by jet's native `test.each` function.
|
* [#205](https://github.com/shlinkio/shlink-web-client/issues/205) Replaced `jest-each` package by jet's native `test.each` function.
|
||||||
* [#209](https://github.com/shlinkio/shlink-web-client/issues/209) Replaced `Unknown` by `Direct` for visits from undetermined referrers.
|
* [#209](https://github.com/shlinkio/shlink-web-client/issues/209) Replaced `Unknown` by `Direct` for visits from undetermined referrers.
|
||||||
* [#212](https://github.com/shlinkio/shlink-web-client/issues/212) Moved copy-to-clipboard next to short URL.
|
* [#212](https://github.com/shlinkio/shlink-web-client/issues/212) Moved copy-to-clipboard next to short URL.
|
||||||
* [#208](https://github.com/shlinkio/shlink-web-client/issues/208) Short URLs list paginator is now progressive.
|
* [#208](https://github.com/shlinkio/shlink-web-client/issues/208) Short URLs list paginator is now progressive.
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* [#243](https://github.com/shlinkio/shlink-web-client/issues/243) Fixed loading state and resetting on short URL creation form.
|
* [#243](https://github.com/shlinkio/shlink-web-client/issues/243) Fixed loading state and resetting on short URL creation form.
|
||||||
* [#239](https://github.com/shlinkio/shlink-web-client/issues/239) Fixed how user agents are parsed, reducing false results.
|
* [#239](https://github.com/shlinkio/shlink-web-client/issues/239) Fixed how user agents are parsed, reducing false results.
|
||||||
|
|
||||||
|
|
||||||
## 2.3.1 - 2020-02-08
|
## [2.3.1] - 2020-02-08
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* [#191](https://github.com/shlinkio/shlink-web-client/issues/191) Created `ForServerVersion` helper component which dynamically renders children if current server conditions are met.
|
* [#191](https://github.com/shlinkio/shlink-web-client/issues/191) Created `ForServerVersion` helper component which dynamically renders children if current server conditions are met.
|
||||||
* [#189](https://github.com/shlinkio/shlink-web-client/issues/189) Simplified short url tags and short url deletion components and reducers, by removing redundant actions.
|
* [#189](https://github.com/shlinkio/shlink-web-client/issues/189) Simplified short url tags and short url deletion components and reducers, by removing redundant actions.
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* [#193](https://github.com/shlinkio/shlink-web-client/issues/193) Fixed `maxVisits` being set to 0 when trying to reset it from having a value to `null`.
|
* [#193](https://github.com/shlinkio/shlink-web-client/issues/193) Fixed `maxVisits` being set to 0 when trying to reset it from having a value to `null`.
|
||||||
* [#196](https://github.com/shlinkio/shlink-web-client/issues/196) Included apache `.htaccess` file which takes care of falling back to index.html when reloading the page on a client-side handled route.
|
* [#196](https://github.com/shlinkio/shlink-web-client/issues/196) Included apache `.htaccess` file which takes care of falling back to index.html when reloading the page on a client-side handled route.
|
||||||
* [#179](https://github.com/shlinkio/shlink-web-client/issues/179) Ensured domain is provided to Shlink server when editing, deleting or fetching short URLs which do not belong to default domain.
|
* [#179](https://github.com/shlinkio/shlink-web-client/issues/179) Ensured domain is provided to Shlink server when editing, deleting or fetching short URLs which do not belong to default domain.
|
||||||
* [#202](https://github.com/shlinkio/shlink-web-client/issues/202) Fixed domain not passed when dispatching actions that affect a single short URL (edit tags, edit meta and delete), which cased the list not to be properly updated.
|
* [#202](https://github.com/shlinkio/shlink-web-client/issues/202) Fixed domain not passed when dispatching actions that affect a single short URL (edit tags, edit meta and delete), which cased the list not to be properly updated.
|
||||||
|
|
||||||
|
|
||||||
## 2.3.0 - 2020-01-19
|
## [2.3.0] - 2020-01-19
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* [#174](https://github.com/shlinkio/shlink-web-client/issues/174) Added complete support for Shlink v2.x together with currently supported Shlink versions.
|
* [#174](https://github.com/shlinkio/shlink-web-client/issues/174) Added complete support for Shlink v2.x together with currently supported Shlink versions.
|
||||||
* [#164](https://github.com/shlinkio/shlink-web-client/issues/164) Added max visits control on those URLs which have `maxVisits`.
|
* [#164](https://github.com/shlinkio/shlink-web-client/issues/164) Added max visits control on those URLs which have `maxVisits`.
|
||||||
* [#178](https://github.com/shlinkio/shlink-web-client/issues/178) Short URLs list can now be filtered by date range.
|
* [#178](https://github.com/shlinkio/shlink-web-client/issues/178) Short URLs list can now be filtered by date range.
|
||||||
* [#46](https://github.com/shlinkio/shlink-web-client/issues/46) Allowed short URL's metadata to be edited (`maxVisits`, `validSince` and `validUntil`).
|
* [#46](https://github.com/shlinkio/shlink-web-client/issues/46) Allowed short URL's metadata to be edited (`maxVisits`, `validSince` and `validUntil`).
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* [#170](https://github.com/shlinkio/shlink-web-client/issues/170) Fixed apple icon referencing to incorrect file names.
|
* [#170](https://github.com/shlinkio/shlink-web-client/issues/170) Fixed apple icon referencing to incorrect file names.
|
||||||
|
|
||||||
|
|
||||||
## 2.2.2 - 2019-10-21
|
## [2.2.2] - 2019-10-21
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* [#167](https://github.com/shlinkio/shlink-web-client/issues/167) Fixed `/servers.json` path not being ignored when returning something other than an array.
|
* [#167](https://github.com/shlinkio/shlink-web-client/issues/167) Fixed `/servers.json` path not being ignored when returning something other than an array.
|
||||||
|
|
||||||
|
|
||||||
## 2.2.1 - 2019-10-18
|
## [2.2.1] - 2019-10-18
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* [#165](https://github.com/shlinkio/shlink-web-client/issues/165) Fixed error thrown when opening "create" page while using a Shlink version which does not return a valid SemVer version (like `latest` docker image, or any development instance).
|
* [#165](https://github.com/shlinkio/shlink-web-client/issues/165) Fixed error thrown when opening "create" page while using a Shlink version which does not return a valid SemVer version (like `latest` docker image, or any development instance).
|
||||||
|
|
||||||
|
|
||||||
## 2.2.0 - 2019-10-05
|
## [2.2.0] - 2019-10-05
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* [#144](https://github.com/shlinkio/shlink-web-client/issues/144) Added domain input to create domain page.
|
* [#144](https://github.com/shlinkio/shlink-web-client/issues/144) Added domain input to create domain page.
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* [#140](https://github.com/shlinkio/shlink-web-client/issues/140) Updated project dependencies.
|
* [#140](https://github.com/shlinkio/shlink-web-client/issues/140) Updated project dependencies.
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
|
|
||||||
## 2.1.1 - 2019-09-22
|
## [2.1.1] - 2019-09-22
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* [#142](https://github.com/shlinkio/shlink-web-client/issues/142) Updated to newer versions of base docker images for dev and production.
|
* [#142](https://github.com/shlinkio/shlink-web-client/issues/142) Updated to newer versions of base docker images for dev and production.
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* [#151](https://github.com/shlinkio/shlink-web-client/issues/151) Fixed "order by" indicator (caret) still indicate ASC on column header when no order is specified.
|
* [#151](https://github.com/shlinkio/shlink-web-client/issues/151) Fixed "order by" indicator (caret) still indicate ASC on column header when no order is specified.
|
||||||
* [#157](https://github.com/shlinkio/shlink-web-client/issues/157) Fixed pagination control on graphs expanding too much when lots of pages need to be rendered.
|
* [#157](https://github.com/shlinkio/shlink-web-client/issues/157) Fixed pagination control on graphs expanding too much when lots of pages need to be rendered.
|
||||||
* [#155](https://github.com/shlinkio/shlink-web-client/issues/155) Fixed client-side paths resolve to 404 when served from nginx in docker image instead of falling back to `index.html`.
|
* [#155](https://github.com/shlinkio/shlink-web-client/issues/155) Fixed client-side paths resolve to 404 when served from nginx in docker image instead of falling back to `index.html`.
|
||||||
|
|
||||||
|
|
||||||
## 2.1.0 - 2019-05-19
|
## [2.1.0] - 2019-05-19
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* [#101](https://github.com/shlinkio/shlink-web-client/issues/101) Added checkbox to short URL creation form that allows to determine the value of the `findIfExists` flag introduced in Shlink v1.16.0.
|
* [#101](https://github.com/shlinkio/shlink-web-client/issues/101) Added checkbox to short URL creation form that allows to determine the value of the `findIfExists` flag introduced in Shlink v1.16.0.
|
||||||
* [#105](https://github.com/shlinkio/shlink-web-client/issues/105) Added support to pre-configure servers. See [how to pre-configure servers](README.md#pre-configuring-servers) to get more details on how to do it.
|
* [#105](https://github.com/shlinkio/shlink-web-client/issues/105) Added support to pre-configure servers. See [how to pre-configure servers](README.md#pre-configuring-servers) to get more details on how to do it.
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* [#125](https://github.com/shlinkio/shlink-web-client/issues/125) Refactored reducers to replace `switch` statements by `handleActions` from [redux-actions](https://github.com/redux-utilities/redux-actions).
|
* [#125](https://github.com/shlinkio/shlink-web-client/issues/125) Refactored reducers to replace `switch` statements by `handleActions` from [redux-actions](https://github.com/redux-utilities/redux-actions).
|
||||||
* [#116](https://github.com/shlinkio/shlink-web-client/issues/116) Removed sinon in favor of jest mocks.
|
* [#116](https://github.com/shlinkio/shlink-web-client/issues/116) Removed sinon in favor of jest mocks.
|
||||||
* [#72](https://github.com/shlinkio/shlink-web-client/issues/72) Increased code coverage up to 80%.
|
* [#72](https://github.com/shlinkio/shlink-web-client/issues/72) Increased code coverage up to 80%.
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
|
|
||||||
## 2.0.3 - 2019-03-16
|
## [2.0.3] - 2019-03-16
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* [#120](https://github.com/shlinkio/shlink-web-client/issues/120) Fixed crash when visits page is loaded and there are no visits with known cities.
|
* [#120](https://github.com/shlinkio/shlink-web-client/issues/120) Fixed crash when visits page is loaded and there are no visits with known cities.
|
||||||
* [#113](https://github.com/shlinkio/shlink-web-client/issues/113) Ensured visits loading is cancelled when the visits page is unmounted. Requests on flight will still finish.
|
* [#113](https://github.com/shlinkio/shlink-web-client/issues/113) Ensured visits loading is cancelled when the visits page is unmounted. Requests on flight will still finish.
|
||||||
* [#118](https://github.com/shlinkio/shlink-web-client/issues/118) Fixed chart crashing when trying to render lots of bars by adding pagination.
|
* [#118](https://github.com/shlinkio/shlink-web-client/issues/118) Fixed chart crashing when trying to render lots of bars by adding pagination.
|
||||||
|
|
||||||
|
|
||||||
## 2.0.2 - 2019-03-04
|
## [2.0.2] - 2019-03-04
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* [#103](https://github.com/shlinkio/shlink-web-client/issues/103) Fixed visits page getting freezed when loading large amounts of visits.
|
* [#103](https://github.com/shlinkio/shlink-web-client/issues/103) Fixed visits page getting freezed when loading large amounts of visits.
|
||||||
* [#111](https://github.com/shlinkio/shlink-web-client/issues/111) Fixed crash when trying to load a map modal with only one location.
|
* [#111](https://github.com/shlinkio/shlink-web-client/issues/111) Fixed crash when trying to load a map modal with only one location.
|
||||||
* [#115](https://github.com/shlinkio/shlink-web-client/issues/115) Created `ErrorHandler` component which will prevent crashes in app to make it unusable.
|
* [#115](https://github.com/shlinkio/shlink-web-client/issues/115) Created `ErrorHandler` component which will prevent crashes in app to make it unusable.
|
||||||
|
|
||||||
|
|
||||||
## 2.0.1 - 2019-03-03
|
## [2.0.1] - 2019-03-03
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* [#106](https://github.com/shlinkio/shlink-web-client/issues/106) Reduced size of docker image by using a multi-stage build Dockerfile.
|
* [#106](https://github.com/shlinkio/shlink-web-client/issues/106) Reduced size of docker image by using a multi-stage build Dockerfile.
|
||||||
* [#95](https://github.com/shlinkio/shlink-web-client/issues/95) Tested docker image build during travis executions.
|
* [#95](https://github.com/shlinkio/shlink-web-client/issues/95) Tested docker image build during travis executions.
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* [#104](https://github.com/shlinkio/shlink-web-client/issues/104) Fixed blank page being showed when not-found paths are loaded.
|
* [#104](https://github.com/shlinkio/shlink-web-client/issues/104) Fixed blank page being showed when not-found paths are loaded.
|
||||||
* [#94](https://github.com/shlinkio/shlink-web-client/issues/94) Fixed initial zoom and center on maps.
|
* [#94](https://github.com/shlinkio/shlink-web-client/issues/94) Fixed initial zoom and center on maps.
|
||||||
* [#93](https://github.com/shlinkio/shlink-web-client/issues/93) Prevented side menu to be swipeable while a modal window is displayed.
|
* [#93](https://github.com/shlinkio/shlink-web-client/issues/93) Prevented side menu to be swipeable while a modal window is displayed.
|
||||||
|
|
||||||
|
|
||||||
## 2.0.0 - 2019-01-13
|
## [2.0.0] - 2019-01-13
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* [#54](https://github.com/shlinkio/shlink-web-client/issues/54) Added stats by city graphic in visits page.
|
* [#54](https://github.com/shlinkio/shlink-web-client/issues/54) Added stats by city graphic in visits page.
|
||||||
* [#55](https://github.com/shlinkio/shlink-web-client/issues/55) Added map in visits page locating cities from which visits have occurred.
|
* [#55](https://github.com/shlinkio/shlink-web-client/issues/55) Added map in visits page locating cities from which visits have occurred.
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* [#87](https://github.com/shlinkio/shlink-web-client/issues/87) and [#89](https://github.com/shlinkio/shlink-web-client/issues/89) Updated all dependencies to latest major versions.
|
* [#87](https://github.com/shlinkio/shlink-web-client/issues/87) and [#89](https://github.com/shlinkio/shlink-web-client/issues/89) Updated all dependencies to latest major versions.
|
||||||
* [#96](https://github.com/shlinkio/shlink-web-client/issues/96) Updated visits page to load visits in multiple paginated requests of `5000` visits when used shlink server supports it. This will prevent shlink to hang when trying to load big amounts of visits.
|
* [#96](https://github.com/shlinkio/shlink-web-client/issues/96) Updated visits page to load visits in multiple paginated requests of `5000` visits when used shlink server supports it. This will prevent shlink to hang when trying to load big amounts of visits.
|
||||||
* [#71](https://github.com/shlinkio/shlink-web-client/issues/71) Improved tests and increased code coverage.
|
* [#71](https://github.com/shlinkio/shlink-web-client/issues/71) Improved tests and increased code coverage.
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* [#59](https://github.com/shlinkio/shlink-web-client/issues/59) Dropped support for old browsers. Internet explorer and dead browsers are no longer supported.
|
* [#59](https://github.com/shlinkio/shlink-web-client/issues/59) Dropped support for old browsers. Internet explorer and dead browsers are no longer supported.
|
||||||
* [#97](https://github.com/shlinkio/shlink-web-client/issues/97) Dropped support for authentication via `Authorization` header with Bearer type and JWT, which will make this version no longer work with shlink earlier than v1.13.0.
|
* [#97](https://github.com/shlinkio/shlink-web-client/issues/97) Dropped support for authentication via `Authorization` header with Bearer type and JWT, which will make this version no longer work with shlink earlier than v1.13.0.
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
|
|
||||||
## 1.2.1 - 2018-12-21
|
## [1.2.1] - 2018-12-21
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* [#80](https://github.com/shlinkio/shlink-web-client/issues/80) Deeply refactored app to do true dependency injection with an IoC container.
|
* [#80](https://github.com/shlinkio/shlink-web-client/issues/80) Deeply refactored app to do true dependency injection with an IoC container.
|
||||||
* [#79](https://github.com/shlinkio/shlink-web-client/issues/79) Updated to nginx 1.15.7 as the base docker image.
|
* [#79](https://github.com/shlinkio/shlink-web-client/issues/79) Updated to nginx 1.15.7 as the base docker image.
|
||||||
* [#75](https://github.com/shlinkio/shlink-web-client/issues/75) Prevented duplicated `yarn build` in travis when a tag exists.
|
* [#75](https://github.com/shlinkio/shlink-web-client/issues/75) Prevented duplicated `yarn build` in travis when a tag exists.
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* [#77](https://github.com/shlinkio/shlink-web-client/issues/77) Sortable graphs ordering is now case insensitive.
|
* [#77](https://github.com/shlinkio/shlink-web-client/issues/77) Sortable graphs ordering is now case insensitive.
|
||||||
|
|
||||||
|
|
||||||
## 1.2.0 - 2018-11-01
|
## [1.2.0] - 2018-11-01
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* [#65](https://github.com/shlinkio/shlink-web-client/issues/65) Added sorting to both countries and referrers stats graphs.
|
* [#65](https://github.com/shlinkio/shlink-web-client/issues/65) Added sorting to both countries and referrers stats graphs.
|
||||||
* [#14](https://github.com/shlinkio/shlink-web-client/issues/14) Documented how to build the project so that it can be served from a subpath.
|
* [#14](https://github.com/shlinkio/shlink-web-client/issues/14) Documented how to build the project so that it can be served from a subpath.
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* [#50](https://github.com/shlinkio/shlink-web-client/issues/50) Improved tests and increased code coverage.
|
* [#50](https://github.com/shlinkio/shlink-web-client/issues/50) Improved tests and increased code coverage.
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* [#66](https://github.com/shlinkio/shlink-web-client/issues/66) Fixed tooltips in graphs with too small bars not being displayed.
|
* [#66](https://github.com/shlinkio/shlink-web-client/issues/66) Fixed tooltips in graphs with too small bars not being displayed.
|
||||||
|
|
||||||
|
|
||||||
## 1.1.1 - 2018-10-20
|
## [1.1.1] - 2018-10-20
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* [#57](https://github.com/shlinkio/shlink-web-client/issues/57) Automated release generation in travis build.
|
* [#57](https://github.com/shlinkio/shlink-web-client/issues/57) Automated release generation in travis build.
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* [#63](https://github.com/shlinkio/shlink-web-client/issues/63) Improved how bar charts are rendered in stats page, making them try to calculate a bigger height for big data sets.
|
* [#63](https://github.com/shlinkio/shlink-web-client/issues/63) Improved how bar charts are rendered in stats page, making them try to calculate a bigger height for big data sets.
|
||||||
* [#56](https://github.com/shlinkio/shlink-web-client/issues/56) Ensured `ColorGenerator` matches keys in a case insensitive way.
|
* [#56](https://github.com/shlinkio/shlink-web-client/issues/56) Ensured `ColorGenerator` matches keys in a case insensitive way.
|
||||||
* [#53](https://github.com/shlinkio/shlink-web-client/issues/53) Fixed missing margin between date fields in visits page for mobile devices.
|
* [#53](https://github.com/shlinkio/shlink-web-client/issues/53) Fixed missing margin between date fields in visits page for mobile devices.
|
||||||
|
|
||||||
|
|
||||||
## 1.1.0 - 2018-09-16
|
## [1.1.0] - 2018-09-16
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* [#47](https://github.com/shlinkio/shlink-web-client/issues/47) Added support to delete short URLs (requires [shlink v1.12.0](https://github.com/shlinkio/shlink/releases/tag/v1.12.0) or greater).
|
* [#47](https://github.com/shlinkio/shlink-web-client/issues/47) Added support to delete short URLs (requires [shlink v1.12.0](https://github.com/shlinkio/shlink/releases/tag/v1.12.0) or greater).
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* [#35](https://github.com/shlinkio/shlink-web-client/issues/35) Visits component split into two, which makes the header not to be refreshed when filtering by date, and also the visits global counter now reflects the actual number of visits which fulfill current filter.
|
* [#35](https://github.com/shlinkio/shlink-web-client/issues/35) Visits component split into two, which makes the header not to be refreshed when filtering by date, and also the visits global counter now reflects the actual number of visits which fulfill current filter.
|
||||||
* [#36](https://github.com/shlinkio/shlink-web-client/issues/36) Tags selector now autocompletes existing tag names, to prevent typos and ease reusing existing tags.
|
* [#36](https://github.com/shlinkio/shlink-web-client/issues/36) Tags selector now autocompletes existing tag names, to prevent typos and ease reusing existing tags.
|
||||||
* [#39](https://github.com/shlinkio/shlink-web-client/issues/39) Defined `propTypes` as static properties in class components.
|
* [#39](https://github.com/shlinkio/shlink-web-client/issues/39) Defined `propTypes` as static properties in class components.
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* [#49](https://github.com/shlinkio/shlink-web-client/issues/49) Ensured filtering parameters are reseted when list component is unmounted so that params are not mixed when coming back.
|
* [#49](https://github.com/shlinkio/shlink-web-client/issues/49) Ensured filtering parameters are reseted when list component is unmounted so that params are not mixed when coming back.
|
||||||
* [#45](https://github.com/shlinkio/shlink-web-client/issues/45) Ensured graphs x-axis start at `0` and don't use decimals.
|
* [#45](https://github.com/shlinkio/shlink-web-client/issues/45) Ensured graphs x-axis start at `0` and don't use decimals.
|
||||||
* [#51](https://github.com/shlinkio/shlink-web-client/issues/51) When editing short URL tags, the value returned form server is used when refreshing the list, which is normalized.
|
* [#51](https://github.com/shlinkio/shlink-web-client/issues/51) When editing short URL tags, the value returned form server is used when refreshing the list, which is normalized.
|
||||||
|
|
||||||
|
|
||||||
## 1.0.1 - 2018-09-02
|
## [1.0.1] - 2018-09-02
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* [#42](https://github.com/shlinkio/shlink-web-client/issues/42) Fixed selected tags lost when navigating between pages in short URLs list.
|
* [#42](https://github.com/shlinkio/shlink-web-client/issues/42) Fixed selected tags lost when navigating between pages in short URLs list.
|
||||||
* [#43](https://github.com/shlinkio/shlink-web-client/issues/43) Fixed "List short URLs" menu item only selected when in first page.
|
* [#43](https://github.com/shlinkio/shlink-web-client/issues/43) Fixed "List short URLs" menu item only selected when in first page.
|
||||||
|
|
||||||
|
|
||||||
## 1.0.0 - 2018-08-26
|
## [1.0.0] - 2018-08-26
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* [#4](https://github.com/shlinkio/shlink-web-client/issues/4) Now it is possible to export and import servers.
|
* [#4](https://github.com/shlinkio/shlink-web-client/issues/4) Now it is possible to export and import servers.
|
||||||
|
|
||||||
* Export all servers in a CSV file.
|
* Export all servers in a CSV file.
|
||||||
@@ -566,69 +536,53 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
* [#22](https://github.com/shlinkio/shlink-web-client/issues/22) Improved code coverage.
|
* [#22](https://github.com/shlinkio/shlink-web-client/issues/22) Improved code coverage.
|
||||||
* [#28](https://github.com/shlinkio/shlink-web-client/issues/28) Added integration with [Scrutinizer](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/).
|
* [#28](https://github.com/shlinkio/shlink-web-client/issues/28) Added integration with [Scrutinizer](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/).
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* [#33](https://github.com/shlinkio/shlink-web-client/issues/33) Changed to [adidas coding style](https://github.com/adidas/js-linter-configs) for Javascript.
|
* [#33](https://github.com/shlinkio/shlink-web-client/issues/33) Changed to [adidas coding style](https://github.com/adidas/js-linter-configs) for Javascript.
|
||||||
* [#32](https://github.com/shlinkio/shlink-web-client/issues/32) Changed to [adidas coding style](https://github.com/adidas/js-linter-configs) for stylesheets.
|
* [#32](https://github.com/shlinkio/shlink-web-client/issues/32) Changed to [adidas coding style](https://github.com/adidas/js-linter-configs) for stylesheets.
|
||||||
* [#26](https://github.com/shlinkio/shlink-web-client/issues/26) The tags input now displays tags using their actual color.
|
* [#26](https://github.com/shlinkio/shlink-web-client/issues/26) The tags input now displays tags using their actual color.
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
|
|
||||||
## 0.2.0 - 2018-08-12
|
## [0.2.0] - 2018-08-12
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* [#12](https://github.com/shlinkio/shlink-web-client/issues/12) Improved code coverage
|
* [#12](https://github.com/shlinkio/shlink-web-client/issues/12) Improved code coverage
|
||||||
* [#20](https://github.com/shlinkio/shlink-web-client/issues/20) Added servers list in welcome page, as well as added link to create one when none exist.
|
* [#20](https://github.com/shlinkio/shlink-web-client/issues/20) Added servers list in welcome page, as well as added link to create one when none exist.
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* [#11](https://github.com/shlinkio/shlink-web-client/issues/11) Improved app icons fro progressive web apps.
|
* [#11](https://github.com/shlinkio/shlink-web-client/issues/11) Improved app icons fro progressive web apps.
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* [#19](https://github.com/shlinkio/shlink-web-client/issues/19) Added workaround in tags input so that it is possible to add tags on Android devices.
|
* [#19](https://github.com/shlinkio/shlink-web-client/issues/19) Added workaround in tags input so that it is possible to add tags on Android devices.
|
||||||
* [#17](https://github.com/shlinkio/shlink-web-client/issues/17) Fixed short URLs list not being sortable in mobile resolutions.
|
* [#17](https://github.com/shlinkio/shlink-web-client/issues/17) Fixed short URLs list not being sortable in mobile resolutions.
|
||||||
* [#13](https://github.com/shlinkio/shlink-web-client/issues/13) Improved visits page on mobile resolutions.
|
* [#13](https://github.com/shlinkio/shlink-web-client/issues/13) Improved visits page on mobile resolutions.
|
||||||
|
|
||||||
|
|
||||||
## 0.1.1 - 2018-08-06
|
## [0.1.1] - 2018-08-06
|
||||||
|
### Added
|
||||||
#### Added
|
|
||||||
|
|
||||||
* [#15](https://github.com/shlinkio/shlink-web-client/issues/15) Added a `Dockerfile` that can be used to generate a distributable docker image
|
* [#15](https://github.com/shlinkio/shlink-web-client/issues/15) Added a `Dockerfile` that can be used to generate a distributable docker image
|
||||||
|
|
||||||
#### Changed
|
### Changed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Removed
|
### Removed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
#### Fixed
|
### Fixed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
FROM node:12.16.3-alpine as node
|
FROM node:14.15-alpine as node
|
||||||
COPY . /shlink-web-client
|
COPY . /shlink-web-client
|
||||||
ARG VERSION="latest"
|
ARG VERSION="latest"
|
||||||
ENV VERSION ${VERSION}
|
ENV VERSION ${VERSION}
|
||||||
RUN cd /shlink-web-client && \
|
RUN cd /shlink-web-client && \
|
||||||
npm install && npm run build -- ${VERSION} --no-dist
|
npm install && npm run build -- ${VERSION} --no-dist
|
||||||
|
|
||||||
FROM nginx:1.17.10-alpine
|
FROM nginx:1.19.6-alpine
|
||||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||||
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
|
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
|
||||||
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
# shlink-web-client
|
# shlink-web-client
|
||||||
|
|
||||||
[](https://travis-ci.org/shlinkio/shlink-web-client)
|
[](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
|
||||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/)
|
[](https://app.codecov.io/gh/shlinkio/shlink-web-client)
|
||||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/)
|
|
||||||
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||||
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||||
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
||||||
[](https://slnk.to/donate)
|
[](https://slnk.to/donate)
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,26 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Expire rules for static content
|
||||||
|
# HTML files should never be cached. There's only one here, which is the entry point (index.html)
|
||||||
|
location ~* \.(?:manifest|appcache|html?|xml|json)$ {
|
||||||
|
expires -1;
|
||||||
|
}
|
||||||
|
# Images and other binary assets can be saved for a month
|
||||||
|
location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ {
|
||||||
|
expires 1M;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
# JS and CSS files can be saved for a year, as they are always hashed. New versions will include a new hash anyway, forcing the download
|
||||||
|
location ~* \.(?:css|js)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
# When requesting static paths with extension, try them, and return a 404 if not found
|
# When requesting static paths with extension, try them, and return a 404 if not found
|
||||||
location ~* .+\.(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
|
location ~* .+\.(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
|
||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# When requesting a path without extension, try it, and return the index if not found
|
# When requesting a path without extension, try it, and return the index if not found
|
||||||
# This allows HTML5 history paths to be handled by the client application
|
# This allows HTML5 history paths to be handled by the client application
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import Enzyme from 'enzyme';
|
import Enzyme from 'enzyme';
|
||||||
import Adapter from 'enzyme-adapter-react-16';
|
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||||
|
|
||||||
Enzyme.configure({ adapter: new Adapter() });
|
Enzyme.configure({ adapter: new Adapter() });
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
|
|||||||
const safePostCssParser = require('postcss-safe-parser');
|
const safePostCssParser = require('postcss-safe-parser');
|
||||||
const ManifestPlugin = require('webpack-manifest-plugin');
|
const ManifestPlugin = require('webpack-manifest-plugin');
|
||||||
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
|
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
|
||||||
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
|
|
||||||
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
|
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
|
||||||
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
|
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
|
||||||
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
|
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
|
||||||
@@ -611,25 +610,6 @@ module.exports = (webpackEnv) => {
|
|||||||
// You can remove this if you don't use Moment.js:
|
// You can remove this if you don't use Moment.js:
|
||||||
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
|
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
|
||||||
|
|
||||||
// Generate a service worker script that will precache, and keep up to date,
|
|
||||||
// the HTML & assets that are part of the Webpack build.
|
|
||||||
isEnvProduction &&
|
|
||||||
new WorkboxWebpackPlugin.GenerateSW({
|
|
||||||
clientsClaim: true,
|
|
||||||
exclude: [ /\.map$/, /asset-manifest\.json$/ ],
|
|
||||||
importWorkboxFrom: 'cdn',
|
|
||||||
navigateFallback: `${publicUrl}/index.html`,
|
|
||||||
navigateFallbackBlacklist: [
|
|
||||||
|
|
||||||
// Exclude URLs starting with /_, as they're likely an API call
|
|
||||||
new RegExp('^/_'),
|
|
||||||
|
|
||||||
// Exclude URLs containing a dot, as they're likely a resource in
|
|
||||||
// public/ and not a SPA route
|
|
||||||
new RegExp('/[^/]+\\.[^/]+$'),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
|
|
||||||
// TypeScript type checking
|
// TypeScript type checking
|
||||||
useTypeScript &&
|
useTypeScript &&
|
||||||
new ForkTsCheckerWebpackPlugin({
|
new ForkTsCheckerWebpackPlugin({
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ module.exports = function(proxy, allowedHost) {
|
|||||||
// We do this in development to avoid hitting the production cache if
|
// We do this in development to avoid hitting the production cache if
|
||||||
// it used the same host and port.
|
// it used the same host and port.
|
||||||
// https://github.com/facebook/create-react-app/issues/2272#issuecomment-302832432
|
// https://github.com/facebook/create-react-app/issues/2272#issuecomment-302832432
|
||||||
app.use(noopServiceWorkerMiddleware());
|
app.use(noopServiceWorkerMiddleware(paths.publicUrl));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ version: '3'
|
|||||||
services:
|
services:
|
||||||
shlink_web_client_node:
|
shlink_web_client_node:
|
||||||
container_name: shlink_web_client_node
|
container_name: shlink_web_client_node
|
||||||
image: node:12.16.3-alpine
|
image: node:14.15-alpine
|
||||||
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/home/shlink/www
|
- ./:/home/shlink/www
|
||||||
|
|||||||
19320
package-lock.json
generated
19320
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
196
package.json
196
package.json
@@ -6,7 +6,7 @@
|
|||||||
"repository": "https://github.com/shlinkio/shlink-web-client",
|
"repository": "https://github.com/shlinkio/shlink-web-client",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "npm run lint:js && npm run lint:css",
|
"lint": "npm run lint:css && npm run lint:js",
|
||||||
"lint:js": "eslint --ext .js,.ts,.tsx src test scripts config",
|
"lint:js": "eslint --ext .js,.ts,.tsx src test scripts config",
|
||||||
"lint:js:fix": "npm run lint:js -- --fix",
|
"lint:js:fix": "npm run lint:js -- --fix",
|
||||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||||
@@ -17,146 +17,146 @@
|
|||||||
"test": "node scripts/test.js --env=jsdom --colors",
|
"test": "node scripts/test.js --env=jsdom --colors",
|
||||||
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
||||||
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
|
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
|
||||||
"mutate": "./node_modules/.bin/stryker run",
|
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
|
||||||
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^5.14.0",
|
"@fortawesome/fontawesome-free": "^5.15.1",
|
||||||
"@fortawesome/fontawesome-svg-core": "^1.2.30",
|
"@fortawesome/fontawesome-svg-core": "^1.2.32",
|
||||||
"@fortawesome/free-regular-svg-icons": "^5.14.0",
|
"@fortawesome/free-regular-svg-icons": "^5.15.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.14.0",
|
"@fortawesome/free-solid-svg-icons": "^5.15.1",
|
||||||
"@fortawesome/react-fontawesome": "^0.1.11",
|
"@fortawesome/react-fontawesome": "^0.1.12",
|
||||||
"array-filter": "^1.0.0",
|
"axios": "^0.21.0",
|
||||||
"array-map": "^0.0.0",
|
"bootstrap": "^4.5.3",
|
||||||
"array-reduce": "^0.0.0",
|
|
||||||
"axios": "^0.20.0",
|
|
||||||
"bootstrap": "^4.5.2",
|
|
||||||
"bottlejs": "^2.0.0",
|
"bottlejs": "^2.0.0",
|
||||||
"bowser": "^2.10.0",
|
"bowser": "^2.11.0",
|
||||||
"chart.js": "^2.9.3",
|
"chart.js": "^2.9.4",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
"compare-versions": "^3.6.0",
|
"compare-versions": "^3.6.0",
|
||||||
"csvjson": "^5.1.0",
|
"csvjson": "^5.1.0",
|
||||||
"event-source-polyfill": "^1.0.17",
|
"event-source-polyfill": "^1.0.21",
|
||||||
"leaflet": "^1.7.1",
|
"leaflet": "^1.7.1",
|
||||||
"moment": "^2.27.0",
|
"moment": "^2.29.1",
|
||||||
"promise": "^8.0.3",
|
"promise": "^8.1.0",
|
||||||
"qs": "^6.9.4",
|
"qs": "^6.9.4",
|
||||||
"ramda": "^0.27.1",
|
"ramda": "^0.27.1",
|
||||||
"react": "^16.13.1",
|
"react": "^17.0.1",
|
||||||
"react-autosuggest": "^10.0.2",
|
"react-autosuggest": "^10.0.3",
|
||||||
"react-chartjs-2": "^2.10.0",
|
"react-chartjs-2": "^2.11.1",
|
||||||
"react-color": "^2.18.1",
|
"react-color": "^2.19.3",
|
||||||
"react-copy-to-clipboard": "^5.0.2",
|
"react-copy-to-clipboard": "^5.0.2",
|
||||||
"react-datepicker": "~1.5.0",
|
"react-datepicker": "^3.3.0",
|
||||||
"react-dom": "^16.13.1",
|
"react-dom": "^17.0.1",
|
||||||
"react-external-link": "^1.1.1",
|
"react-external-link": "^1.2.0",
|
||||||
"react-leaflet": "^2.7.0",
|
"react-leaflet": "^3.0.2",
|
||||||
"react-moment": "^0.9.7",
|
"react-moment": "^1.0.0",
|
||||||
"react-redux": "^7.2.1",
|
"react-redux": "^7.2.2",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"react-swipeable": "^5.5.1",
|
"react-swipeable": "^6.0.0",
|
||||||
"react-tagsinput": "^3.19.0",
|
"react-tagsinput": "^3.19.0",
|
||||||
"reactstrap": "^8.0.1",
|
"reactstrap": "^8.7.1",
|
||||||
"redux": "^4.0.4",
|
"redux": "^4.0.5",
|
||||||
"redux-localstorage-simple": "^2.2.0",
|
"redux-localstorage-simple": "^2.3.1",
|
||||||
"redux-thunk": "^2.3.0",
|
"redux-thunk": "^2.3.0",
|
||||||
"uuid": "^3.3.3"
|
"uuid": "^8.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.6.2",
|
"@babel/core": "^7.12.3",
|
||||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4",
|
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1",
|
||||||
"@babel/plugin-proposal-optional-chaining": "^7.11.0",
|
"@babel/plugin-proposal-optional-chaining": "^7.12.1",
|
||||||
"@shlinkio/eslint-config-js-coding-standard": "~1.1.0",
|
"@shlinkio/eslint-config-js-coding-standard": "~1.1.0",
|
||||||
"@stryker-mutator/core": "^3.2.4",
|
"@stryker-mutator/core": "^4.3.1",
|
||||||
"@stryker-mutator/typescript": "^3.2.4",
|
"@stryker-mutator/jest-runner": "^4.3.1",
|
||||||
"@stryker-mutator/jest-runner": "^3.2.4",
|
"@stryker-mutator/typescript-checker": "^4.3.1",
|
||||||
"@svgr/webpack": "^4.3.3",
|
"@svgr/webpack": "^5.4.0",
|
||||||
"@types/chart.js": "^2.9.24",
|
"@types/chart.js": "^2.9.27",
|
||||||
"@types/classnames": "^2.2.10",
|
"@types/classnames": "^2.2.11",
|
||||||
"@types/enzyme": "^3.10.5",
|
"@types/enzyme": "^3.10.8",
|
||||||
"@types/jest": "^26.0.10",
|
"@types/jest": "^26.0.15",
|
||||||
"@types/leaflet": "^1.5.17",
|
"@types/leaflet": "^1.5.19",
|
||||||
"@types/moment": "^2.13.0",
|
"@types/moment": "^2.13.0",
|
||||||
"@types/qs": "^6.9.4",
|
"@types/qs": "^6.9.5",
|
||||||
"@types/ramda": "^0.27.14",
|
"@types/ramda": "^0.27.32",
|
||||||
"@types/react": "^16.9.46",
|
"@types/react": "^16.9.56",
|
||||||
"@types/react-autosuggest": "^10.0.0",
|
"@types/react-autosuggest": "^10.0.1",
|
||||||
"@types/react-color": "^2.17.4",
|
"@types/react-color": "^3.0.4",
|
||||||
"@types/react-copy-to-clipboard": "^4.3.0",
|
"@types/react-copy-to-clipboard": "^4.3.0",
|
||||||
"@types/react-datepicker": "~1.8.0",
|
"@types/react-datepicker": "^3.1.1",
|
||||||
"@types/react-dom": "^16.9.8",
|
"@types/react-dom": "^16.9.9",
|
||||||
"@types/react-leaflet": "^2.5.2",
|
"@types/react-leaflet": "^2.5.2",
|
||||||
"@types/react-redux": "^7.1.9",
|
"@types/react-redux": "^7.1.11",
|
||||||
"@types/react-router-dom": "^5.1.5",
|
"@types/react-router-dom": "^5.1.6",
|
||||||
"@types/react-tagsinput": "^3.19.7",
|
"@types/react-tagsinput": "^3.19.7",
|
||||||
"@types/reactstrap": "^8.5.1",
|
|
||||||
"@types/uuid": "^8.3.0",
|
"@types/uuid": "^8.3.0",
|
||||||
"adm-zip": "^0.4.13",
|
"@typescript-eslint/parser": "^4.7.0",
|
||||||
"autoprefixer": "^9.6.3",
|
"@wojtekmaj/enzyme-adapter-react-17": "^0.3.1",
|
||||||
|
"adm-zip": "^0.4.16",
|
||||||
|
"autoprefixer": "^10.0.2",
|
||||||
"babel-core": "7.0.0-bridge.0",
|
"babel-core": "7.0.0-bridge.0",
|
||||||
"babel-jest": "^26.3.0",
|
"babel-jest": "^26.6.3",
|
||||||
"babel-loader": "^8.0.6",
|
"babel-loader": "^8.2.1",
|
||||||
"babel-plugin-named-asset-import": "^0.3.4",
|
"babel-plugin-named-asset-import": "^0.3.7",
|
||||||
"babel-preset-react-app": "^9.0.2",
|
"babel-preset-react-app": "^10.0.0",
|
||||||
"babel-runtime": "^6.26.0",
|
"babel-runtime": "^6.26.0",
|
||||||
"bfj": "^7.0.1",
|
"bfj": "^7.0.2",
|
||||||
"case-sensitive-paths-webpack-plugin": "^2.2.0",
|
"case-sensitive-paths-webpack-plugin": "^2.3.0",
|
||||||
"chalk": "^2.4.2",
|
"chalk": "^4.1.0",
|
||||||
"css-loader": "^3.2.0",
|
"css-loader": "^5.0.1",
|
||||||
"dotenv": "^8.1.0",
|
"dart-sass": "^1.25.0",
|
||||||
|
"dotenv": "^8.2.0",
|
||||||
"dotenv-expand": "^5.1.0",
|
"dotenv-expand": "^5.1.0",
|
||||||
"enzyme": "^3.11.0",
|
"enzyme": "^3.11.0",
|
||||||
"enzyme-adapter-react-16": "^1.15.2",
|
"eslint": "^7.13.0",
|
||||||
"eslint": "^6.8.0",
|
"eslint-loader": "^4.0.2",
|
||||||
"eslint-loader": "^3.0.2",
|
"file-loader": "^6.2.0",
|
||||||
"file-loader": "^4.2.0",
|
|
||||||
"fork-ts-checker-webpack-plugin-alt": "^0.4.14",
|
"fork-ts-checker-webpack-plugin-alt": "^0.4.14",
|
||||||
"fs-extra": "^8.1.0",
|
"fs-extra": "^9.0.1",
|
||||||
"html-webpack-plugin": "^4.0.0-beta.8",
|
"html-webpack-plugin": "^4.5.0",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^26.4.2",
|
"jest": "^26.6.3",
|
||||||
"jest-pnp-resolver": "^1.2.2",
|
"jest-pnp-resolver": "^1.2.2",
|
||||||
"jest-resolve": "^26.4.0",
|
"jest-resolve": "^26.6.2",
|
||||||
"mini-css-extract-plugin": "^0.8.0",
|
"mini-css-extract-plugin": "^1.3.1",
|
||||||
"node-sass": "^4.14.1",
|
|
||||||
"object-assign": "^4.1.1",
|
"object-assign": "^4.1.1",
|
||||||
"ocular.js": "^0.1.0",
|
"optimize-css-assets-webpack-plugin": "^5.0.4",
|
||||||
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
"pnp-webpack-plugin": "^1.6.4",
|
||||||
"pnp-webpack-plugin": "^1.5.0",
|
"postcss": "^8.1.7",
|
||||||
"postcss": "^7.0.18",
|
"postcss-flexbugs-fixes": "^4.2.1",
|
||||||
"postcss-flexbugs-fixes": "^4.1.0",
|
|
||||||
"postcss-loader": "^3.0.0",
|
"postcss-loader": "^3.0.0",
|
||||||
"postcss-preset-env": "^6.7.0",
|
"postcss-preset-env": "^6.7.0",
|
||||||
"postcss-safe-parser": "^4.0.1",
|
"postcss-safe-parser": "^5.0.2",
|
||||||
"raf": "^3.4.1",
|
"raf": "^3.4.1",
|
||||||
"react-app-polyfill": "^1.0.6",
|
"react-app-polyfill": "^2.0.0",
|
||||||
"react-dev-utils": "^9.1.0",
|
"react-dev-utils": "^11.0.0",
|
||||||
"resolve": "^1.12.0",
|
"resolve": "^1.19.0",
|
||||||
"sass-loader": "^10.0.2",
|
"sass": "^1.29.0",
|
||||||
|
"sass-loader": "^10.1.0",
|
||||||
"serve": "^11.3.2",
|
"serve": "^11.3.2",
|
||||||
"stryker-cli": "^1.0.0",
|
"stryker-cli": "^1.0.0",
|
||||||
"style-loader": "^1.2.1",
|
"style-loader": "^2.0.0",
|
||||||
"stylelint": "^13.7.0",
|
"stylelint": "^13.7.2",
|
||||||
"stylelint-config-adidas": "^1.3.0",
|
"stylelint-config-adidas": "^1.3.0",
|
||||||
"stylelint-config-adidas-bem": "^1.2.0",
|
"stylelint-config-adidas-bem": "^1.2.0",
|
||||||
"stylelint-config-recommended-scss": "^4.2.0",
|
"stylelint-config-recommended-scss": "^4.2.0",
|
||||||
"stylelint-scss": "^3.18.0",
|
"stylelint-scss": "^3.18.0",
|
||||||
"sw-precache-webpack-plugin": "^0.11.5",
|
"sw-precache-webpack-plugin": "^1.0.0",
|
||||||
"terser-webpack-plugin": "^2.1.2",
|
"terser-webpack-plugin": "^4.2.3",
|
||||||
"ts-jest": "^26.3.0",
|
"ts-jest": "^26.4.4",
|
||||||
"ts-mockery": "^1.2.0",
|
"ts-mockery": "^1.2.0",
|
||||||
"typescript": "^3.9.7",
|
"typescript": "^4.0.5",
|
||||||
"url-loader": "^2.2.0",
|
"url-loader": "^4.1.1",
|
||||||
"webpack": "^4.41.0",
|
"webpack": "^4.44.2",
|
||||||
"webpack-dev-server": "^3.8.2",
|
"webpack-dev-server": "^3.11.0",
|
||||||
"webpack-manifest-plugin": "^2.2.0",
|
"webpack-manifest-plugin": "^2.2.0",
|
||||||
"whatwg-fetch": "^3.0.0",
|
"whatwg-fetch": "^3.5.0"
|
||||||
"workbox-webpack-plugin": "^4.3.1"
|
|
||||||
},
|
},
|
||||||
"babel": {
|
"babel": {
|
||||||
"presets": [
|
"presets": [
|
||||||
"react-app"
|
[
|
||||||
|
"react-app",
|
||||||
|
{
|
||||||
|
"runtime": "automatic"
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@babel/plugin-proposal-optional-chaining",
|
"@babel/plugin-proposal-optional-chaining",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
manifest.json provides metadata used when your web app is added to the
|
manifest.json provides metadata used when your web app is added to the
|
||||||
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
||||||
-->
|
-->
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" crossorigin="use-credentials">
|
||||||
|
|
||||||
<!-- FavIcon itself -->
|
<!-- FavIcon itself -->
|
||||||
<link rel="icon" type="image/x-icon" href="%PUBLIC_URL%/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="%PUBLIC_URL%/favicon.ico">
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
set -ex
|
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"
|
DOCKER_IMAGE="shlinkio/shlink-web-client"
|
||||||
|
|
||||||
if [[ "$GITHUB_REF" == *"main"* ]]; then
|
if [[ "$GITHUB_REF" == *"main"* ]]; then
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 2.3 MiB |
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, FC } from 'react';
|
import { useEffect, FC } from 'react';
|
||||||
import { Route, Switch } from 'react-router-dom';
|
import { Route, Switch } from 'react-router-dom';
|
||||||
import NotFound from './common/NotFound';
|
import NotFound from './common/NotFound';
|
||||||
import { ServersMap } from './servers/data';
|
import { ServersMap } from './servers/data';
|
||||||
@@ -16,7 +16,7 @@ const App = (
|
|||||||
CreateServer: FC,
|
CreateServer: FC,
|
||||||
EditServer: FC,
|
EditServer: FC,
|
||||||
Settings: FC,
|
Settings: FC,
|
||||||
ShlinkVersions: FC,
|
ShlinkVersionsContainer: FC,
|
||||||
) => ({ fetchServers, servers }: AppProps) => {
|
) => ({ fetchServers, servers }: AppProps) => {
|
||||||
// On first load, try to fetch the remote servers if the list is empty
|
// On first load, try to fetch the remote servers if the list is empty
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,8 +41,8 @@ const App = (
|
|||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shlink-footer text-center text-md-right">
|
<div className="shlink-footer">
|
||||||
<ShlinkVersions />
|
<ShlinkVersionsContainer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
16
src/api/ShlinkApiError.tsx
Normal file
16
src/api/ShlinkApiError.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { ProblemDetailsError } from './types';
|
||||||
|
import { isInvalidArgumentError } from './utils';
|
||||||
|
|
||||||
|
export interface ShlinkApiErrorProps {
|
||||||
|
errorData?: ProblemDetailsError;
|
||||||
|
fallbackMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShlinkApiError = ({ errorData, fallbackMessage }: ShlinkApiErrorProps) => (
|
||||||
|
<>
|
||||||
|
{errorData?.detail ?? fallbackMessage}
|
||||||
|
{isInvalidArgumentError(errorData) &&
|
||||||
|
<p className="mb-0">Invalid elements: [{errorData.invalidElements.join(', ')}]</p>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
@@ -3,7 +3,7 @@ import { isEmpty, isNil, reject } from 'ramda';
|
|||||||
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
||||||
import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
|
import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||||
import { ShortUrl, ShortUrlData } from '../../short-urls/data';
|
import { ShortUrl, ShortUrlData } from '../../short-urls/data';
|
||||||
import { OptionalString } from '../utils';
|
import { OptionalString } from '../../utils/utils';
|
||||||
import {
|
import {
|
||||||
ShlinkHealth,
|
ShlinkHealth,
|
||||||
ShlinkMercureInfo,
|
ShlinkMercureInfo,
|
||||||
@@ -13,7 +13,10 @@ import {
|
|||||||
ShlinkVisits,
|
ShlinkVisits,
|
||||||
ShlinkVisitsParams,
|
ShlinkVisitsParams,
|
||||||
ShlinkShortUrlMeta,
|
ShlinkShortUrlMeta,
|
||||||
} from './types';
|
ShlinkDomain,
|
||||||
|
ShlinkDomainsResponse,
|
||||||
|
ShlinkVisitsOverview,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
||||||
const rejectNilProps = reject(isNil);
|
const rejectNilProps = reject(isNil);
|
||||||
@@ -48,6 +51,10 @@ export default class ShlinkApiClient {
|
|||||||
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
|
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
|
||||||
.then(({ data }) => data.visits);
|
.then(({ data }) => data.visits);
|
||||||
|
|
||||||
|
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
|
||||||
|
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
|
||||||
|
.then(({ data }) => data.visits);
|
||||||
|
|
||||||
public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise<ShortUrl> =>
|
public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise<ShortUrl> =>
|
||||||
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain })
|
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain })
|
||||||
.then(({ data }) => data);
|
.then(({ data }) => data);
|
||||||
@@ -93,6 +100,9 @@ export default class ShlinkApiClient {
|
|||||||
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
|
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
|
||||||
.then((resp) => resp.data);
|
.then((resp) => resp.data);
|
||||||
|
|
||||||
|
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
|
||||||
|
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data);
|
||||||
|
|
||||||
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
|
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
|
||||||
try {
|
try {
|
||||||
return await this.axios({
|
return await this.axios({
|
||||||
@@ -115,7 +125,7 @@ export default class ShlinkApiClient {
|
|||||||
|
|
||||||
// When the request is not invalid or we have already tried both API versions, throw the error and let the
|
// When the request is not invalid or we have already tried both API versions, throw the error and let the
|
||||||
// caller handle it
|
// caller handle it
|
||||||
if (!apiVersionIsNotSupported || this.apiVersion === 1) {
|
if (!apiVersionIsNotSupported || this.apiVersion === 2) {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
8
src/api/services/provideServices.ts
Normal file
8
src/api/services/provideServices.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import Bottle from 'bottlejs';
|
||||||
|
import buildShlinkApiClient from './ShlinkApiClientBuilder';
|
||||||
|
|
||||||
|
const provideServices = (bottle: Bottle) => {
|
||||||
|
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Visit } from '../../visits/types'; // FIXME Should be defined as part of this module
|
import { Visit } from '../../visits/types';
|
||||||
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data'; // FIXME Should be defined as part of this module
|
import { OptionalString } from '../../utils/utils';
|
||||||
import { OptionalString } from '../utils';
|
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
|
||||||
|
|
||||||
export interface ShlinkShortUrlsResponse {
|
export interface ShlinkShortUrlsResponse {
|
||||||
data: ShortUrl[];
|
data: ShortUrl[];
|
||||||
@@ -25,22 +25,27 @@ interface ShlinkTagsStats {
|
|||||||
|
|
||||||
export interface ShlinkTags {
|
export interface ShlinkTags {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
stats?: ShlinkTagsStats[]; // Is only optional in old Shlink versions
|
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkTagsResponse {
|
export interface ShlinkTagsResponse {
|
||||||
data: string[];
|
data: string[];
|
||||||
stats?: ShlinkTagsStats[]; // Is only optional in old Shlink versions
|
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkPaginator {
|
export interface ShlinkPaginator {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
pagesCount: number;
|
pagesCount: number;
|
||||||
|
totalItems: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkVisits {
|
export interface ShlinkVisits {
|
||||||
data: Visit[];
|
data: Visit[];
|
||||||
pagination?: ShlinkPaginator; // Is only optional in old Shlink versions
|
pagination: ShlinkPaginator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkVisitsOverview {
|
||||||
|
visitsCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkVisitsParams {
|
export interface ShlinkVisitsParams {
|
||||||
@@ -55,12 +60,30 @@ export interface ShlinkShortUrlMeta extends ShortUrlMeta {
|
|||||||
longUrl?: string;
|
longUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShlinkDomain {
|
||||||
|
domain: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkDomainsResponse {
|
||||||
|
data: ShlinkDomain[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProblemDetailsError {
|
export interface ProblemDetailsError {
|
||||||
type: string;
|
type: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: number;
|
status: number;
|
||||||
error?: string; // Deprecated
|
|
||||||
message?: string; // Deprecated
|
|
||||||
[extraProps: string]: any;
|
[extraProps: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InvalidArgumentError extends ProblemDetailsError {
|
||||||
|
type: 'INVALID_ARGUMENT';
|
||||||
|
invalidElements: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvalidShortUrlDeletion extends ProblemDetailsError {
|
||||||
|
type: 'INVALID_SHORTCODE_DELETION';
|
||||||
|
threshold: number;
|
||||||
|
}
|
||||||
10
src/api/utils/index.ts
Normal file
10
src/api/utils/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import { InvalidArgumentError, InvalidShortUrlDeletion, ProblemDetailsError } from '../types';
|
||||||
|
|
||||||
|
export const parseApiError = (e: AxiosError<ProblemDetailsError>) => e.response?.data;
|
||||||
|
|
||||||
|
export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError =>
|
||||||
|
error?.type === 'INVALID_ARGUMENT';
|
||||||
|
|
||||||
|
export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion =>
|
||||||
|
error?.type === 'INVALID_SHORTCODE_DELETION';
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
@import '../utils/base';
|
@import '../utils/base';
|
||||||
@import '../utils/mixins/vertical-align';
|
@import '../utils/mixins/vertical-align';
|
||||||
|
|
||||||
$asideMenuMobileWidth: 280px;
|
|
||||||
|
|
||||||
.aside-menu {
|
.aside-menu {
|
||||||
background-color: #f7f7f7;
|
width: $asideMenuWidth;
|
||||||
|
background-color: white;
|
||||||
|
box-shadow: rgba(0, 0, 0, .05) 0 8px 15px;
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
padding-top: 13px;
|
padding-top: 13px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
@@ -22,7 +22,6 @@ $asideMenuMobileWidth: 280px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: $smMax) {
|
@media (max-width: $smMax) {
|
||||||
width: $asideMenuMobileWidth !important;
|
|
||||||
transition: left 300ms;
|
transition: left 300ms;
|
||||||
top: $headerHeight - 3px;
|
top: $headerHeight - 3px;
|
||||||
box-shadow: -10px 0 50px 11px rgba(0, 0, 0, .55);
|
box-shadow: -10px 0 50px 11px rgba(0, 0, 0, .55);
|
||||||
@@ -31,7 +30,7 @@ $asideMenuMobileWidth: 280px;
|
|||||||
|
|
||||||
.aside-menu--hidden {
|
.aside-menu--hidden {
|
||||||
@media (max-width: $smMax) {
|
@media (max-width: $smMax) {
|
||||||
left: -($asideMenuMobileWidth + 35px);
|
left: -($asideMenuWidth + 35px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,10 +43,14 @@ $asideMenuMobileWidth: 280px;
|
|||||||
margin: 0 -15px;
|
margin: 0 -15px;
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
@media (max-width: $smMax) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside-menu__item:hover {
|
.aside-menu__item:hover {
|
||||||
background-color: $lightHoverColor;
|
background-color: $lightColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside-menu__item--selected {
|
.aside-menu__item--selected {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import {
|
|||||||
faLink as createIcon,
|
faLink as createIcon,
|
||||||
faTags as tagsIcon,
|
faTags as tagsIcon,
|
||||||
faPen as editIcon,
|
faPen as editIcon,
|
||||||
|
faHome as overviewIcon,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import React, { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { NavLink, NavLinkProps } from 'react-router-dom';
|
import { NavLink, NavLinkProps } from 'react-router-dom';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Location } from 'history';
|
import { Location } from 'history';
|
||||||
@@ -21,7 +22,6 @@ export interface AsideMenuProps {
|
|||||||
|
|
||||||
interface AsideMenuItemProps extends NavLinkProps {
|
interface AsideMenuItemProps extends NavLinkProps {
|
||||||
to: string;
|
to: string;
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
|
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
|
||||||
@@ -36,10 +36,10 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
|
|||||||
);
|
);
|
||||||
|
|
||||||
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||||
{ selectedServer, className, showOnMobile = false }: AsideMenuProps,
|
{ selectedServer, showOnMobile = false }: AsideMenuProps,
|
||||||
) => {
|
) => {
|
||||||
const serverId = selectedServer ? selectedServer.id : '';
|
const serverId = selectedServer ? selectedServer.id : '';
|
||||||
const asideClass = classNames('aside-menu', className, {
|
const asideClass = classNames('aside-menu', {
|
||||||
'aside-menu--hidden': !showOnMobile,
|
'aside-menu--hidden': !showOnMobile,
|
||||||
});
|
});
|
||||||
const shortUrlsIsActive = (_: null, location: Location) => location.pathname.match('/list-short-urls') !== null;
|
const shortUrlsIsActive = (_: null, location: Location) => location.pathname.match('/list-short-urls') !== null;
|
||||||
@@ -48,6 +48,10 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
|||||||
return (
|
return (
|
||||||
<aside className={asideClass}>
|
<aside className={asideClass}>
|
||||||
<nav className="nav flex-column aside-menu__nav">
|
<nav className="nav flex-column aside-menu__nav">
|
||||||
|
<AsideMenuItem to={buildPath('/overview')}>
|
||||||
|
<FontAwesomeIcon icon={overviewIcon} />
|
||||||
|
<span className="aside-menu__item-text">Overview</span>
|
||||||
|
</AsideMenuItem>
|
||||||
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
||||||
<FontAwesomeIcon icon={listIcon} />
|
<FontAwesomeIcon icon={listIcon} />
|
||||||
<span className="aside-menu__item-text">List short URLs</span>
|
<span className="aside-menu__item-text">List short URLs</span>
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
@import '../utils/mixins/vertical-align.scss';
|
|
||||||
|
|
||||||
.error-handler {
|
|
||||||
@include vertical-align();
|
|
||||||
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import { Component, ReactNode } from 'react';
|
||||||
import { Button } from 'reactstrap';
|
import { Button } from 'reactstrap';
|
||||||
import './ErrorHandler.scss';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
|
||||||
interface ErrorHandlerState {
|
interface ErrorHandlerState {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
@@ -9,7 +9,7 @@ interface ErrorHandlerState {
|
|||||||
const ErrorHandler = (
|
const ErrorHandler = (
|
||||||
{ location }: Window,
|
{ location }: Window,
|
||||||
{ error }: Console,
|
{ error }: Console,
|
||||||
) => class ErrorHandler extends React.Component<any, ErrorHandlerState> {
|
) => class ErrorHandler extends Component<any, ErrorHandlerState> {
|
||||||
public constructor(props: object) {
|
public constructor(props: object) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { hasError: false };
|
this.state = { hasError: false };
|
||||||
@@ -25,14 +25,16 @@ const ErrorHandler = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public render(): ReactNode | undefined {
|
public render(): ReactNode {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<div className="error-handler">
|
<div className="home">
|
||||||
<h1>Oops! This is awkward :S</h1>
|
<SimpleCard className="p-4">
|
||||||
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
<h1>Oops! This is awkward :S</h1>
|
||||||
<br />
|
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
||||||
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
|
<br />
|
||||||
|
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
|
||||||
|
</SimpleCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,41 @@
|
|||||||
@import '../utils/base';
|
@import '../utils/base';
|
||||||
|
@import '../utils/mixins/vertical-align';
|
||||||
|
|
||||||
.home {
|
.home {
|
||||||
text-align: center;
|
position: relative;
|
||||||
height: calc(100vh - #{$headerHeight} - #{($footer-height + $footer-margin)});
|
padding-top: 15px;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
@media (min-width: $mdMin) {
|
||||||
justify-content: center;
|
padding-top: 0;
|
||||||
flex-flow: column;
|
height: calc(100vh - #{$headerHeight} - #{($footer-height + $footer-margin)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__logo {
|
||||||
|
@include vertical-align();
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__main-card {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 720px;
|
||||||
|
|
||||||
|
@media (min-width: $mdMin) {
|
||||||
|
@include vertical-align();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.home__title {
|
.home__title {
|
||||||
|
text-align: center;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
@media (min-width: $mdMin) {
|
||||||
font-size: 2.2rem;
|
font-size: 2.2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home__servers-container {
|
||||||
|
@media (min-width: $mdMin) {
|
||||||
|
border-left: 1px solid rgba(0, 0, 0, .125);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React from 'react';
|
|
||||||
import { isEmpty, values } from 'ramda';
|
import { isEmpty, values } from 'ramda';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Card, Row } from 'reactstrap';
|
||||||
|
import { ExternalLink } from 'react-external-link';
|
||||||
import ServersListGroup from '../servers/ServersListGroup';
|
import ServersListGroup from '../servers/ServersListGroup';
|
||||||
import './Home.scss';
|
import './Home.scss';
|
||||||
import { ServersMap } from '../servers/data';
|
import { ServersMap } from '../servers/data';
|
||||||
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
|
|
||||||
export interface HomeProps {
|
export interface HomeProps {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
@@ -15,11 +17,32 @@ const Home = ({ servers }: HomeProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
<h1 className="home__title">Welcome to Shlink</h1>
|
<Card className="home__main-card">
|
||||||
<ServersListGroup servers={serversList}>
|
<Row noGutters>
|
||||||
{hasServers && <span>Please, select a server.</span>}
|
<div className="col-md-5 d-none d-md-block">
|
||||||
{!hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
|
<div className="p-4">
|
||||||
</ServersListGroup>
|
<ShlinkLogo />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-7 home__servers-container">
|
||||||
|
<div className="p-4">
|
||||||
|
<h1 className="home__title">Welcome!</h1>
|
||||||
|
</div>
|
||||||
|
<ServersListGroup embedded servers={serversList}>
|
||||||
|
{!hasServers && (
|
||||||
|
<div className="p-4">
|
||||||
|
<p>This application will help you to manage your Shlink servers.</p>
|
||||||
|
<p>To start, please, <Link to="/server/create">add your first server</Link>.</p>
|
||||||
|
<p className="m-0">
|
||||||
|
You still don‘t have a Shlink server?
|
||||||
|
Learn how to <ExternalLink href="https://shlink.io/documentation">get started</ExternalLink>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ServersListGroup>
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { faPlus as plusIcon, faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import React, { FC, useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { RouteComponentProps } from 'react-router';
|
import { RouteComponentProps } from 'react-router';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import shlinkLogo from './shlink-logo-white.png';
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
import './MainHeader.scss';
|
import './MainHeader.scss';
|
||||||
|
|
||||||
const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps) => {
|
const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps) => {
|
||||||
@@ -15,14 +15,13 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
|
|||||||
|
|
||||||
useEffect(close, [ location ]);
|
useEffect(close, [ location ]);
|
||||||
|
|
||||||
const createServerPath = '/server/create';
|
|
||||||
const settingsPath = '/settings';
|
const settingsPath = '/settings';
|
||||||
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
||||||
<NavbarBrand tag={Link} to="/">
|
<NavbarBrand tag={Link} to="/">
|
||||||
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
|
<ShlinkLogo className="main-header__brand-logo" color="white" /> Shlink
|
||||||
</NavbarBrand>
|
</NavbarBrand>
|
||||||
|
|
||||||
<NavbarToggler onClick={toggleOpen}>
|
<NavbarToggler onClick={toggleOpen}>
|
||||||
@@ -32,15 +31,10 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
|
|||||||
<Collapse navbar isOpen={isOpen}>
|
<Collapse navbar isOpen={isOpen}>
|
||||||
<Nav navbar className="ml-auto">
|
<Nav navbar className="ml-auto">
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
<NavLink tag={Link} to={'/settings'} active={pathname === settingsPath}>
|
||||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
<NavItem>
|
|
||||||
<NavLink tag={Link} to={createServerPath} active={pathname === createServerPath}>
|
|
||||||
<FontAwesomeIcon icon={plusIcon} /> Add server
|
|
||||||
</NavLink>
|
|
||||||
</NavItem>
|
|
||||||
<ServersDropdown />
|
<ServersDropdown />
|
||||||
</Nav>
|
</Nav>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|||||||
@@ -33,11 +33,11 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-layout__container {
|
.menu-layout__container.menu-layout__container {
|
||||||
padding: 20px 0 0;
|
padding: 20px 0 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
@media (min-width: $mdMin) {
|
||||||
padding: 30px 15px 0;
|
padding: 30px 0 0 $asideMenuWidth;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { FC, useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { Route, Switch } from 'react-router-dom';
|
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||||
import { EventData, Swipeable } from 'react-swipeable';
|
import { useSwipeable } from 'react-swipeable';
|
||||||
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
@@ -20,6 +20,7 @@ const MenuLayout = (
|
|||||||
ShortUrlVisits: FC,
|
ShortUrlVisits: FC,
|
||||||
TagVisits: FC,
|
TagVisits: FC,
|
||||||
ServerError: FC,
|
ServerError: FC,
|
||||||
|
Overview: FC,
|
||||||
) => withSelectedServer(({ location, selectedServer }) => {
|
) => withSelectedServer(({ location, selectedServer }) => {
|
||||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ const MenuLayout = (
|
|||||||
const burgerClasses = classNames('menu-layout__burger-icon', {
|
const burgerClasses = classNames('menu-layout__burger-icon', {
|
||||||
'menu-layout__burger-icon--active': sidebarVisible,
|
'menu-layout__burger-icon--active': sidebarVisible,
|
||||||
});
|
});
|
||||||
const swipeMenuIfNoModalExists = (callback: () => void) => (e: EventData) => {
|
const swipeMenuIfNoModalExists = (callback: () => void) => (e: any) => {
|
||||||
const swippedOnVisitsTable = (e.event.composedPath() as HTMLElement[]).some(
|
const swippedOnVisitsTable = (e.event.composedPath() as HTMLElement[]).some(
|
||||||
({ classList }) => classList?.contains('visits-table'),
|
({ classList }) => classList?.contains('visits-table'),
|
||||||
);
|
);
|
||||||
@@ -44,26 +45,28 @@ const MenuLayout = (
|
|||||||
|
|
||||||
callback();
|
callback();
|
||||||
};
|
};
|
||||||
|
const swipeableProps = useSwipeable({
|
||||||
|
delta: 40,
|
||||||
|
onSwipedLeft: swipeMenuIfNoModalExists(hideSidebar),
|
||||||
|
onSwipedRight: swipeMenuIfNoModalExists(showSidebar),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<>
|
||||||
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
||||||
|
|
||||||
<Swipeable
|
<div {...swipeableProps} className="menu-layout__swipeable">
|
||||||
delta={40}
|
<div className="menu-layout__swipeable-inner">
|
||||||
className="menu-layout__swipeable"
|
<AsideMenu selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
||||||
onSwipedLeft={swipeMenuIfNoModalExists(hideSidebar)}
|
<div className="menu-layout__container" onClick={() => hideSidebar()}>
|
||||||
onSwipedRight={swipeMenuIfNoModalExists(showSidebar)}
|
<div className="container-xl">
|
||||||
>
|
|
||||||
<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>
|
<Switch>
|
||||||
|
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
|
||||||
|
<Route exact path="/server/:serverId/overview" component={Overview} />
|
||||||
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
||||||
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
||||||
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||||
{addTagsVisitsRoute && <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
{addTagsVisitsRoute && <Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
||||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||||
<Route
|
<Route
|
||||||
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||||
@@ -72,8 +75,8 @@ const MenuLayout = (
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Swipeable>
|
</div>
|
||||||
</React.Fragment>
|
</>
|
||||||
);
|
);
|
||||||
}, ServerError);
|
}, ServerError);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
@import '../utils/base';
|
||||||
|
|
||||||
.no-menu-wrapper {
|
.no-menu-wrapper {
|
||||||
padding: 40px 20px 20px;
|
padding: 15px 0 0;
|
||||||
|
|
||||||
|
@media (min-width: $mdMin) {
|
||||||
|
padding: 30px 20px 20px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import './NoMenuLayout.scss';
|
import './NoMenuLayout.scss';
|
||||||
|
|
||||||
const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper">{children}</div>;
|
const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper container-xl">{children}</div>;
|
||||||
|
|
||||||
export default NoMenuLayout;
|
export default NoMenuLayout;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
|
||||||
interface NotFoundProps {
|
interface NotFoundProps {
|
||||||
to?: string;
|
to?: string;
|
||||||
@@ -7,13 +8,15 @@ interface NotFoundProps {
|
|||||||
|
|
||||||
const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
<h2>Oops! We could not find requested route.</h2>
|
<SimpleCard className="p-4">
|
||||||
<p>
|
<h2>Oops! We could not find requested route.</h2>
|
||||||
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
<p>
|
||||||
button.
|
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
||||||
</p>
|
button.
|
||||||
<br />
|
</p>
|
||||||
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
|
<br />
|
||||||
|
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
|
||||||
|
</SimpleCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { PropsWithChildren, useEffect } from 'react';
|
import { PropsWithChildren, useEffect } from 'react';
|
||||||
import { RouteComponentProps } from 'react-router';
|
import { RouteComponentProps } from 'react-router';
|
||||||
|
|
||||||
const ScrollToTop = () => ({ location, children }: PropsWithChildren<RouteComponentProps>) => {
|
const ScrollToTop = () => ({ location, children }: PropsWithChildren<RouteComponentProps>) => {
|
||||||
@@ -6,7 +6,7 @@ const ScrollToTop = () => ({ location, children }: PropsWithChildren<RouteCompon
|
|||||||
scrollTo(0, 0);
|
scrollTo(0, 0);
|
||||||
}, [ location ]);
|
}, [ location ]);
|
||||||
|
|
||||||
return <React.Fragment>{children}</React.Fragment>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ScrollToTop;
|
export default ScrollToTop;
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import React from 'react';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { pipe } from 'ramda';
|
import { pipe } from 'ramda';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
||||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
import { isReachableServer } from '../servers/data';
|
||||||
|
import { ShlinkVersionsContainerProps } from './ShlinkVersionsContainer';
|
||||||
|
|
||||||
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
||||||
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
|
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
|
||||||
|
|
||||||
export interface ShlinkVersionsProps {
|
export interface ShlinkVersionsProps extends ShlinkVersionsContainerProps {
|
||||||
selectedServer: SelectedServer;
|
|
||||||
clientVersion?: string;
|
clientVersion?: string;
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
|
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
|
||||||
@@ -20,15 +17,13 @@ const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-cli
|
|||||||
</ExternalLink>
|
</ExternalLink>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ShlinkVersions = (
|
const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps) => {
|
||||||
{ selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps,
|
|
||||||
) => {
|
|
||||||
const normalizedClientVersion = normalizeVersion(clientVersion);
|
const normalizedClientVersion = normalizeVersion(clientVersion);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<small className={classNames('text-muted', className)}>
|
<small className="text-muted">
|
||||||
{isReachableServer(selectedServer) &&
|
{isReachableServer(selectedServer) &&
|
||||||
<React.Fragment>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </React.Fragment>
|
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
|
||||||
}
|
}
|
||||||
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} />
|
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} />
|
||||||
</small>
|
</small>
|
||||||
|
|||||||
9
src/common/ShlinkVersionsContainer.scss
Normal file
9
src/common/ShlinkVersionsContainer.scss
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
@import '../utils/base';
|
||||||
|
|
||||||
|
.shlink-versions-container--with-server {
|
||||||
|
margin-left: 0;
|
||||||
|
|
||||||
|
@media (min-width: $mdMin) {
|
||||||
|
margin-left: $asideMenuWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/common/ShlinkVersionsContainer.tsx
Normal file
22
src/common/ShlinkVersionsContainer.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||||
|
import ShlinkVersions from './ShlinkVersions';
|
||||||
|
import './ShlinkVersionsContainer.scss';
|
||||||
|
|
||||||
|
export interface ShlinkVersionsContainerProps {
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => {
|
||||||
|
const classes = classNames('text-center', {
|
||||||
|
'shlink-versions-container--with-server': isReachableServer(selectedServer),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes}>
|
||||||
|
<ShlinkVersions selectedServer={selectedServer} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShlinkVersionsContainer;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||||
import {
|
import {
|
||||||
|
|||||||
25
src/common/img/ShlinkLogo.tsx
Normal file
25
src/common/img/ShlinkLogo.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { MAIN_COLOR } from '../../utils/theme';
|
||||||
|
|
||||||
|
export interface ShlinkLogoProps {
|
||||||
|
color?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShlinkLogo = ({ color = MAIN_COLOR, className }: ShlinkLogoProps) => (
|
||||||
|
<svg className={className} viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g fill={color}>
|
||||||
|
<path
|
||||||
|
d=" M 23.71 85.08 C 17.22 49.81 49.44 14.86 85.08 18.12 C 118.83 19.21 145.72 53.33 139.45 86.37 C 155.64 102.30 171.32 118.83 187.87 134.36 C 198.32 111.73 208.84 89.12 219.57 66.62 C 226.05 53.84 243.47 48.74 255.73 56.27 C 263.76 62.10 270.34 69.69 277.25 76.75 C 286.28 86.61 285.72 102.89 276.31 112.31 C 223.38 165.37 170.38 218.37 117.35 271.34 C 107.72 280.99 91.01 281.25 81.11 271.86 C 74.39 264.94 66.82 258.69 61.24 250.77 C 53.72 238.52 58.85 221.07 71.64 214.62 C 94.11 203.87 116.72 193.38 139.33 182.91 C 123.81 166.36 107.30 150.68 91.37 134.49 C 60.20 140.28 27.37 116.78 23.71 85.08 Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d=" M 205.21 201.23 C 225.32 181.36 260.88 181.11 281.14 200.86 C 299.25 218.75 317.37 236.65 335.10 254.93 C 356.73 278.01 352.01 318.70 326.03 336.56 C 320.07 330.47 313.73 324.65 308.12 318.28 C 323.86 309.39 328.76 286.18 316.63 272.39 C 301.73 256.95 286.30 242.03 271.24 226.75 C 264.49 219.65 256.80 212.00 246.37 211.52 C 224.65 208.64 205.52 233.36 214.49 253.58 C 221.09 266.81 234.22 275.12 243.62 286.24 C 240.43 295.96 238.09 306.13 238.29 316.46 C 225.55 304.29 213.16 291.73 200.89 279.09 C 180.97 257.57 183.10 220.45 205.21 201.23 Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d=" M 273.90 352.07 C 252.28 328.99 256.98 288.31 282.96 270.46 C 288.93 276.54 295.26 282.36 300.88 288.72 C 285.14 297.62 280.23 320.82 292.38 334.61 C 307.27 350.05 322.70 364.96 337.75 380.25 C 344.51 387.35 352.20 395.00 362.64 395.48 C 384.35 398.37 403.49 373.64 394.51 353.42 C 387.92 340.18 374.78 331.88 365.38 320.76 C 368.56 311.04 370.91 300.86 370.71 290.54 C 383.45 302.70 395.84 315.27 408.11 327.91 C 428.03 349.43 425.90 386.55 403.78 405.77 C 383.68 425.64 348.13 425.89 327.86 406.14 C 309.75 388.25 291.60 370.37 273.90 352.07 Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d=" M 422.11 403.83 C 431.96 394.07 441.60 384.06 451.66 374.51 C 460.90 383.74 471.89 392.70 474.89 406.11 C 480.16 429.97 484.08 454.13 488.76 478.12 C 490.00 483.41 484.47 488.29 479.35 486.63 C 454.66 481.52 429.55 478.12 405.14 471.84 C 393.17 467.97 385.20 457.75 376.55 449.27 C 386.39 439.49 396.13 429.60 406.06 419.91 C 416.37 433.45 435.74 414.00 422.11 403.83 Z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
border-radius: .25rem;
|
border-radius: .25rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 2.6rem;
|
min-height: 2.6rem;
|
||||||
padding: 6px 0 0 6px;
|
padding: .5rem 0 0 1rem;
|
||||||
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,9 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: 3px 5px;
|
padding: 1px 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #495057;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import axios from 'axios';
|
||||||
import Bottle, { Decorator } from 'bottlejs';
|
import Bottle, { Decorator } from 'bottlejs';
|
||||||
import ScrollToTop from '../ScrollToTop';
|
import ScrollToTop from '../ScrollToTop';
|
||||||
import MainHeader from '../MainHeader';
|
import MainHeader from '../MainHeader';
|
||||||
@@ -5,13 +6,14 @@ import Home from '../Home';
|
|||||||
import MenuLayout from '../MenuLayout';
|
import MenuLayout from '../MenuLayout';
|
||||||
import AsideMenu from '../AsideMenu';
|
import AsideMenu from '../AsideMenu';
|
||||||
import ErrorHandler from '../ErrorHandler';
|
import ErrorHandler from '../ErrorHandler';
|
||||||
import ShlinkVersions from '../ShlinkVersions';
|
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||||
bottle.constant('window', (global as any).window);
|
bottle.constant('window', (global as any).window);
|
||||||
bottle.constant('console', global.console);
|
bottle.constant('console', global.console);
|
||||||
|
bottle.constant('axios', axios);
|
||||||
|
|
||||||
bottle.serviceFactory('ScrollToTop', ScrollToTop);
|
bottle.serviceFactory('ScrollToTop', ScrollToTop);
|
||||||
bottle.decorator('ScrollToTop', withRouter);
|
bottle.decorator('ScrollToTop', withRouter);
|
||||||
@@ -33,14 +35,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
|||||||
'ShortUrlVisits',
|
'ShortUrlVisits',
|
||||||
'TagVisits',
|
'TagVisits',
|
||||||
'ServerError',
|
'ServerError',
|
||||||
|
'Overview',
|
||||||
);
|
);
|
||||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
||||||
bottle.decorator('MenuLayout', withRouter);
|
bottle.decorator('MenuLayout', withRouter);
|
||||||
|
|
||||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||||
|
|
||||||
bottle.serviceFactory('ShlinkVersions', () => ShlinkVersions);
|
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
|
||||||
bottle.decorator('ShlinkVersions', connect([ 'selectedServer' ]));
|
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
||||||
};
|
};
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.7 KiB |
@@ -3,6 +3,7 @@ import { withRouter } from 'react-router-dom';
|
|||||||
import { connect as reduxConnect } from 'react-redux';
|
import { connect as reduxConnect } from 'react-redux';
|
||||||
import { pick } from 'ramda';
|
import { pick } from 'ramda';
|
||||||
import App from '../App';
|
import App from '../App';
|
||||||
|
import provideApiServices from '../api/services/provideServices';
|
||||||
import provideCommonServices from '../common/services/provideServices';
|
import provideCommonServices from '../common/services/provideServices';
|
||||||
import provideShortUrlsServices from '../short-urls/services/provideServices';
|
import provideShortUrlsServices from '../short-urls/services/provideServices';
|
||||||
import provideServersServices from '../servers/services/provideServices';
|
import provideServersServices from '../servers/services/provideServices';
|
||||||
@@ -11,6 +12,7 @@ import provideTagsServices from '../tags/services/provideServices';
|
|||||||
import provideUtilsServices from '../utils/services/provideServices';
|
import provideUtilsServices from '../utils/services/provideServices';
|
||||||
import provideMercureServices from '../mercure/services/provideServices';
|
import provideMercureServices from '../mercure/services/provideServices';
|
||||||
import provideSettingsServices from '../settings/services/provideServices';
|
import provideSettingsServices from '../settings/services/provideServices';
|
||||||
|
import provideDomainsServices from '../domains/services/provideServices';
|
||||||
import { ConnectDecorator } from './types';
|
import { ConnectDecorator } from './types';
|
||||||
|
|
||||||
type LazyActionMap = Record<string, Function>;
|
type LazyActionMap = Record<string, Function>;
|
||||||
@@ -30,10 +32,21 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
|
|||||||
actionServiceNames.reduce(mapActionService, {}),
|
actionServiceNames.reduce(mapActionService, {}),
|
||||||
);
|
);
|
||||||
|
|
||||||
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer', 'Settings', 'ShlinkVersions');
|
bottle.serviceFactory(
|
||||||
|
'App',
|
||||||
|
App,
|
||||||
|
'MainHeader',
|
||||||
|
'Home',
|
||||||
|
'MenuLayout',
|
||||||
|
'CreateServer',
|
||||||
|
'EditServer',
|
||||||
|
'Settings',
|
||||||
|
'ShlinkVersionsContainer',
|
||||||
|
);
|
||||||
bottle.decorator('App', connect([ 'servers' ], [ 'fetchServers' ]));
|
bottle.decorator('App', connect([ 'servers' ], [ 'fetchServers' ]));
|
||||||
|
|
||||||
provideCommonServices(bottle, connect, withRouter);
|
provideCommonServices(bottle, connect, withRouter);
|
||||||
|
provideApiServices(bottle);
|
||||||
provideShortUrlsServices(bottle, connect);
|
provideShortUrlsServices(bottle, connect);
|
||||||
provideServersServices(bottle, connect, withRouter);
|
provideServersServices(bottle, connect, withRouter);
|
||||||
provideTagsServices(bottle, connect);
|
provideTagsServices(bottle, connect);
|
||||||
@@ -41,5 +54,6 @@ provideVisitsServices(bottle, connect);
|
|||||||
provideUtilsServices(bottle);
|
provideUtilsServices(bottle);
|
||||||
provideMercureServices(bottle);
|
provideMercureServices(bottle);
|
||||||
provideSettingsServices(bottle, connect);
|
provideSettingsServices(bottle, connect);
|
||||||
|
provideDomainsServices(bottle, connect);
|
||||||
|
|
||||||
export default container;
|
export default container;
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { TagsList } from '../tags/reducers/tagsList';
|
|||||||
import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail';
|
import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail';
|
||||||
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
||||||
import { TagVisits } from '../visits/reducers/tagVisits';
|
import { TagVisits } from '../visits/reducers/tagVisits';
|
||||||
|
import { DomainsList } from '../domains/reducers/domainsList';
|
||||||
|
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||||
|
|
||||||
export interface ShlinkState {
|
export interface ShlinkState {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
@@ -33,6 +35,8 @@ export interface ShlinkState {
|
|||||||
tagEdit: TagEdition;
|
tagEdit: TagEdition;
|
||||||
mercureInfo: MercureInfo;
|
mercureInfo: MercureInfo;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
|
domainsList: DomainsList;
|
||||||
|
visitsOverview: VisitsOverview;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||||
|
|||||||
12
src/domains/DomainSelector.scss
Normal file
12
src/domains/DomainSelector.scss
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
@import '../utils/mixins/vertical-align';
|
||||||
|
|
||||||
|
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active,
|
||||||
|
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:hover,
|
||||||
|
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:active {
|
||||||
|
color: #495057 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domains-dropdown__back-btn.domains-dropdown__back-btn,
|
||||||
|
.domains-dropdown__back-btn.domains-dropdown__back-btn:hover {
|
||||||
|
border-color: #ced4da;
|
||||||
|
}
|
||||||
75
src/domains/DomainSelector.tsx
Normal file
75
src/domains/DomainSelector.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Button, DropdownItem, Input, InputGroup, InputGroupAddon, UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import { InputProps } from 'reactstrap/lib/Input';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faUndo } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { isEmpty, pipe } from 'ramda';
|
||||||
|
import { DropdownBtn } from '../utils/DropdownBtn';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
import { DomainsList } from './reducers/domainsList';
|
||||||
|
import './DomainSelector.scss';
|
||||||
|
|
||||||
|
export interface DomainSelectorProps extends Omit<InputProps, 'onChange'> {
|
||||||
|
value?: string;
|
||||||
|
onChange: (domain: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DomainSelectorConnectProps extends DomainSelectorProps {
|
||||||
|
listDomains: Function;
|
||||||
|
domainsList: DomainsList;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DomainSelector = ({ listDomains, value, domainsList, onChange }: DomainSelectorConnectProps) => {
|
||||||
|
const [ inputDisplayed,, showInput, hideInput ] = useToggle();
|
||||||
|
const { domains } = domainsList;
|
||||||
|
const valueIsEmpty = isEmpty(value);
|
||||||
|
const unselectDomain = () => onChange('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listDomains();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return inputDisplayed ? (
|
||||||
|
<InputGroup>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
placeholder="Domain"
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon addonType="append">
|
||||||
|
<Button
|
||||||
|
id="backToDropdown"
|
||||||
|
outline
|
||||||
|
type="button"
|
||||||
|
className="domains-dropdown__back-btn"
|
||||||
|
onClick={pipe(unselectDomain, hideInput)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faUndo} />
|
||||||
|
</Button>
|
||||||
|
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
|
||||||
|
Existing domains
|
||||||
|
</UncontrolledTooltip>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
) : (
|
||||||
|
<DropdownBtn
|
||||||
|
text={valueIsEmpty ? 'Domain' : `Domain: ${value}`}
|
||||||
|
className={!valueIsEmpty ? 'domains-dropdown__toggle-btn--active' : ''}
|
||||||
|
>
|
||||||
|
{domains.map(({ domain, isDefault }) => (
|
||||||
|
<DropdownItem
|
||||||
|
key={domain}
|
||||||
|
active={value === domain || isDefault && valueIsEmpty}
|
||||||
|
onClick={() => onChange(domain)}
|
||||||
|
>
|
||||||
|
{domain}
|
||||||
|
{isDefault && <span className="float-right text-muted">default</span>}
|
||||||
|
</DropdownItem>
|
||||||
|
))}
|
||||||
|
<DropdownItem divider />
|
||||||
|
<DropdownItem onClick={pipe(unselectDomain, showInput)}>
|
||||||
|
<i>New domain</i>
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownBtn>
|
||||||
|
);
|
||||||
|
};
|
||||||
49
src/domains/reducers/domainsList.ts
Normal file
49
src/domains/reducers/domainsList.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Action, Dispatch } from 'redux';
|
||||||
|
import { ShlinkDomain } from '../../api/types';
|
||||||
|
import { buildReducer } from '../../utils/helpers/redux';
|
||||||
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
|
import { GetState } from '../../container/types';
|
||||||
|
|
||||||
|
/* eslint-disable padding-line-between-statements */
|
||||||
|
export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
|
||||||
|
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
|
||||||
|
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
|
||||||
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
|
export interface DomainsList {
|
||||||
|
domains: ShlinkDomain[];
|
||||||
|
loading: boolean;
|
||||||
|
error: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListDomainsAction extends Action<string> {
|
||||||
|
domains: ShlinkDomain[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: DomainsList = {
|
||||||
|
domains: [],
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default buildReducer<DomainsList, ListDomainsAction>({
|
||||||
|
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
||||||
|
[LIST_DOMAINS_ERROR]: () => ({ ...initialState, error: true }),
|
||||||
|
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains }),
|
||||||
|
}, initialState);
|
||||||
|
|
||||||
|
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
||||||
|
dispatch: Dispatch,
|
||||||
|
getState: GetState,
|
||||||
|
) => {
|
||||||
|
dispatch({ type: LIST_DOMAINS_START });
|
||||||
|
const { listDomains } = buildShlinkApiClient(getState);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const domains = await listDomains();
|
||||||
|
|
||||||
|
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: LIST_DOMAINS_ERROR });
|
||||||
|
}
|
||||||
|
};
|
||||||
15
src/domains/services/provideServices.ts
Normal file
15
src/domains/services/provideServices.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import Bottle from 'bottlejs';
|
||||||
|
import { ConnectDecorator } from '../../container/types';
|
||||||
|
import { listDomains } from '../reducers/domainsList';
|
||||||
|
import { DomainSelector } from '../DomainSelector';
|
||||||
|
|
||||||
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
// Components
|
||||||
|
bottle.serviceFactory('DomainSelector', () => DomainSelector);
|
||||||
|
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ]));
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
@import './utils/base';
|
@import './utils/base';
|
||||||
|
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
||||||
|
@import './common/react-tagsinput.scss';
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
background: $lightColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -14,6 +17,29 @@ body,
|
|||||||
background-color: $mainColor !important;
|
background-color: $mainColor !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .075);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
background-color: rgba(255, 255, 255, .5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-xl {
|
||||||
|
@media (min-width: $xlgMin) {
|
||||||
|
max-width: 1320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $smMax) {
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-item:not(:disabled) {
|
.dropdown-item:not(:disabled) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -29,11 +55,19 @@ body,
|
|||||||
background-color: $mainColor;
|
background-color: $mainColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-hover tbody tr:hover {
|
||||||
|
background-color: $lightColor;
|
||||||
|
}
|
||||||
|
|
||||||
.react-datepicker__input-container,
|
.react-datepicker__input-container,
|
||||||
.react-datepicker-wrapper {
|
.react-datepicker-wrapper {
|
||||||
display: block !important;
|
display: block !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.react-datepicker-popper {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
@media (max-width: $smMax) {
|
@media (max-width: $smMax) {
|
||||||
margin: 0 auto !important;
|
margin: 0 auto !important;
|
||||||
@@ -48,6 +82,10 @@ body,
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.text-ellipsis {
|
.text-ellipsis {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -65,3 +103,17 @@ body,
|
|||||||
.progress-bar {
|
.progress-bar {
|
||||||
background-color: $mainColor;
|
background-color: $mainColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-xs-block {
|
||||||
|
@media (max-width: $xsMax) {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-md-block {
|
||||||
|
@media (max-width: $mdMax) {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render } from 'react-dom';
|
import { render } from 'react-dom';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { homepage } from '../package.json';
|
import { homepage } from '../package.json';
|
||||||
import registerServiceWorker from './registerServiceWorker';
|
|
||||||
import container from './container';
|
import container from './container';
|
||||||
import store from './container/store';
|
import store from './container/store';
|
||||||
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
||||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import './common/react-tagsinput.scss';
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
|
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
|
||||||
@@ -30,4 +26,3 @@ render(
|
|||||||
</Provider>,
|
</Provider>,
|
||||||
document.getElementById('root'),
|
document.getElementById('root'),
|
||||||
);
|
);
|
||||||
registerServiceWorker();
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { FC, useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { pipe } from 'ramda';
|
import { pipe } from 'ramda';
|
||||||
import { CreateVisit } from '../../visits/types';
|
import { CreateVisit } from '../../visits/types';
|
||||||
import { MercureInfo } from '../reducers/mercureInfo';
|
import { MercureInfo } from '../reducers/mercureInfo';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { ShlinkMercureInfo } from '../../utils/services/types';
|
import { ShlinkMercureInfo } from '../../api/types';
|
||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { buildReducer } from '../../utils/helpers/redux';
|
||||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
|
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import tagDeleteReducer from '../tags/reducers/tagDelete';
|
|||||||
import tagEditReducer from '../tags/reducers/tagEdit';
|
import tagEditReducer from '../tags/reducers/tagEdit';
|
||||||
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
||||||
import settingsReducer from '../settings/reducers/settings';
|
import settingsReducer from '../settings/reducers/settings';
|
||||||
|
import domainsListReducer from '../domains/reducers/domainsList';
|
||||||
|
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
|
||||||
import { ShlinkState } from '../container/types';
|
import { ShlinkState } from '../container/types';
|
||||||
|
|
||||||
export default combineReducers<ShlinkState>({
|
export default combineReducers<ShlinkState>({
|
||||||
@@ -36,4 +38,6 @@ export default combineReducers<ShlinkState>({
|
|||||||
tagEdit: tagEditReducer,
|
tagEdit: tagEditReducer,
|
||||||
mercureInfo: mercureInfoReducer,
|
mercureInfo: mercureInfoReducer,
|
||||||
settings: settingsReducer,
|
settings: settingsReducer,
|
||||||
|
domainsList: domainsListReducer,
|
||||||
|
visitsOverview: visitsOverviewReducer,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
/* 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
|
|
||||||
// it offline capabilities. However, it also means that developers (and users)
|
|
||||||
// will only see deployed updates on the "N+1" visit to a page, since previously
|
|
||||||
// cached resources are updated in the background.
|
|
||||||
|
|
||||||
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
|
|
||||||
// This link also includes instructions on opting out of this behavior.
|
|
||||||
|
|
||||||
/* eslint no-console: "off" */
|
|
||||||
|
|
||||||
const isLocalhost = Boolean(
|
|
||||||
window.location.hostname === 'localhost' ||
|
|
||||||
|
|
||||||
// [::1] is the IPv6 localhost address.
|
|
||||||
window.location.hostname === '[::1]' ||
|
|
||||||
|
|
||||||
// 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}$/,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function register() {
|
|
||||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
|
||||||
// The URL constructor is available in all browsers that support SW.
|
|
||||||
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
|
|
||||||
|
|
||||||
if (publicUrl.origin !== window.location.origin) {
|
|
||||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
|
||||||
// from what our page is served on. This might happen if a CDN is used to
|
|
||||||
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
|
||||||
|
|
||||||
if (isLocalhost) {
|
|
||||||
// This is running on localhost. Lets check if a service worker still exists or not.
|
|
||||||
checkValidServiceWorker(swUrl);
|
|
||||||
|
|
||||||
// Add some additional logging to localhost, pointing developers to the
|
|
||||||
// service worker/PWA documentation.
|
|
||||||
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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is not local host. Just register service worker
|
|
||||||
return registerValidSW(swUrl);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerValidSW(swUrl) {
|
|
||||||
return navigator.serviceWorker
|
|
||||||
.register(swUrl)
|
|
||||||
.then((registration) => {
|
|
||||||
registration.onupdatefound = () => {
|
|
||||||
const installingWorker = registration.installing;
|
|
||||||
|
|
||||||
installingWorker.onstatechange = () => {
|
|
||||||
if (installingWorker.state === 'installed') {
|
|
||||||
if (navigator.serviceWorker.controller) {
|
|
||||||
// At this point, the old content will have been purged and
|
|
||||||
// the fresh content will have been added to the cache.
|
|
||||||
// It's the perfect time to display a "New content is
|
|
||||||
// available; please refresh." message in your web app.
|
|
||||||
console.log('New content is available; please refresh.');
|
|
||||||
} else {
|
|
||||||
// At this point, everything has been precached.
|
|
||||||
// It's the perfect time to display a
|
|
||||||
// "Content is cached for offline use." message.
|
|
||||||
console.log('Content is cached for offline use.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Error during service worker registration:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkValidServiceWorker(swUrl) {
|
|
||||||
// Check if the service worker can be found. If it can't reload the page.
|
|
||||||
fetch(swUrl)
|
|
||||||
.then((response) => {
|
|
||||||
const NOT_FOUND_STATUS = 404;
|
|
||||||
|
|
||||||
// Ensure service worker exists, and that we really are getting a JS file.
|
|
||||||
if (
|
|
||||||
response.status === NOT_FOUND_STATUS ||
|
|
||||||
response.headers.get('content-type').includes('javascript')
|
|
||||||
) {
|
|
||||||
// No service worker found. Probably a different app. Reload the page.
|
|
||||||
return navigator.serviceWorker.ready.then((registration) =>
|
|
||||||
registration.unregister().then(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service worker found. Proceed as normal.
|
|
||||||
return registerValidSW(swUrl);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
console.log(
|
|
||||||
'No internet connection found. App is running in offline mode.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unregister() {
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
navigator.serviceWorker.ready.then((registration) => {
|
|
||||||
registration.unregister();
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { RouterProps } from 'react-router';
|
import { RouterProps } from 'react-router';
|
||||||
import classNames from 'classnames';
|
import { Result } from '../utils/Result';
|
||||||
import NoMenuLayout from '../common/NoMenuLayout';
|
import NoMenuLayout from '../common/NoMenuLayout';
|
||||||
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
@@ -15,19 +15,11 @@ interface CreateServerProps extends RouterProps {
|
|||||||
createServer: (server: ServerWithId) => void;
|
createServer: (server: ServerWithId) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Result: FC<{ type: 'success' | 'error' }> = ({ children, type }) => (
|
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
||||||
<div className="row">
|
<Result type={type}>
|
||||||
<div className="col-md-10 offset-md-1">
|
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
||||||
<div
|
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
||||||
className={classNames('p-2 mt-3 text-white text-center', {
|
</Result>
|
||||||
'bg-main': type === 'success',
|
|
||||||
'bg-danger': type === 'error',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
|
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
|
||||||
@@ -39,18 +31,22 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
|
|||||||
const id = uuid();
|
const id = uuid();
|
||||||
|
|
||||||
createServer({ ...serverData, id });
|
createServer({ ...serverData, id });
|
||||||
push(`/server/${id}/list-short-urls/1`);
|
push(`/server/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<ServerForm onSubmit={handleSubmit}>
|
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}>
|
||||||
<ImportServersBtn onImport={setServersImported} onImportError={setErrorImporting} />
|
<ImportServersBtn onImport={setServersImported} onImportError={setErrorImporting} />
|
||||||
<button className="btn btn-outline-primary">Create server</button>
|
<button className="btn btn-outline-primary">Create server</button>
|
||||||
</ServerForm>
|
</ServerForm>
|
||||||
|
|
||||||
{serversImported && <Result type="success">Servers properly imported. You can now select one from the list :)</Result>}
|
{(serversImported || errorImporting) && (
|
||||||
{errorImporting && <Result type="error">The servers could not be imported. Make sure the format is correct.</Result>}
|
<div className="mt-4">
|
||||||
|
{serversImported && <ImportResult type="success" />}
|
||||||
|
{errorImporting && <ImportResult type="error" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
@@ -17,14 +17,14 @@ const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<D
|
|||||||
const [ isModalOpen, , showModal, hideModal ] = useToggle();
|
const [ isModalOpen, , showModal, hideModal ] = useToggle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<>
|
||||||
<span className={className} onClick={showModal}>
|
<span className={className} onClick={showModal}>
|
||||||
{!children && <FontAwesomeIcon icon={deleteIcon} />}
|
{!children && <FontAwesomeIcon icon={deleteIcon} />}
|
||||||
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
|
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
|
||||||
</React.Fragment>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import { RouterProps } from 'react-router';
|
import { RouterProps } from 'react-router';
|
||||||
import { ServerWithId } from './data';
|
import { ServerWithId } from './data';
|
||||||
@@ -27,7 +26,7 @@ const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }: De
|
|||||||
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
|
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
|
||||||
<p>
|
<p>
|
||||||
<i>
|
<i>
|
||||||
No data will be deleted, only the access to this server will be removed from this host.
|
No data will be deleted, only the access to this server will be removed from this device.
|
||||||
You can create it again at any moment.
|
You can create it again at any moment.
|
||||||
</i>
|
</i>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { Button } from 'reactstrap';
|
import { Button } from 'reactstrap';
|
||||||
import NoMenuLayout from '../common/NoMenuLayout';
|
import NoMenuLayout from '../common/NoMenuLayout';
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
@@ -18,12 +18,16 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
|
|||||||
|
|
||||||
const handleSubmit = (serverData: ServerData) => {
|
const handleSubmit = (serverData: ServerData) => {
|
||||||
editServer(selectedServer.id, serverData);
|
editServer(selectedServer.id, serverData);
|
||||||
push(`/server/${selectedServer.id}/list-short-urls/1`);
|
push(`/server/${selectedServer.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<ServerForm initialValues={selectedServer} onSubmit={handleSubmit}>
|
<ServerForm
|
||||||
|
title={<h5 className="mb-0">Edit "{selectedServer.name}"</h5>}
|
||||||
|
initialValues={selectedServer}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
<Button outline className="mr-2" onClick={goBack}>Cancel</Button>
|
<Button outline className="mr-2" onClick={goBack}>Cancel</Button>
|
||||||
<Button outline color="primary">Save</Button>
|
<Button outline color="primary">Save</Button>
|
||||||
</ServerForm>
|
</ServerForm>
|
||||||
|
|||||||
11
src/servers/Overview.scss
Normal file
11
src/servers/Overview.scss
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
@import '../utils/base';
|
||||||
|
|
||||||
|
.overview__card.overview__card {
|
||||||
|
text-align: center;
|
||||||
|
border-top: 3px solid $mainColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview__card-title {
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
109
src/servers/Overview.tsx
Normal file
109
src/servers/Overview.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { FC, useEffect } from 'react';
|
||||||
|
import { Card, CardBody, CardHeader, CardText, CardTitle } from 'reactstrap';
|
||||||
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
|
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
|
||||||
|
import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
||||||
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
|
import { TagsList } from '../tags/reducers/tagsList';
|
||||||
|
import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
|
||||||
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
|
import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
||||||
|
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||||
|
import { Versions } from '../utils/helpers/version';
|
||||||
|
import { isServerWithId, SelectedServer } from './data';
|
||||||
|
import './Overview.scss';
|
||||||
|
|
||||||
|
interface OverviewConnectProps {
|
||||||
|
shortUrlsList: ShortUrlsListState;
|
||||||
|
listShortUrls: (params: ShortUrlsListParams) => void;
|
||||||
|
listTags: Function;
|
||||||
|
tagsList: TagsList;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
visitsOverview: VisitsOverview;
|
||||||
|
loadVisitsOverview: Function;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Overview = (
|
||||||
|
ShortUrlsTable: FC<ShortUrlsTableProps>,
|
||||||
|
CreateShortUrl: FC<CreateShortUrlProps>,
|
||||||
|
ForServerVersion: FC<Versions>,
|
||||||
|
) => boundToMercureHub(({
|
||||||
|
shortUrlsList,
|
||||||
|
listShortUrls,
|
||||||
|
listTags,
|
||||||
|
tagsList,
|
||||||
|
selectedServer,
|
||||||
|
loadVisitsOverview,
|
||||||
|
visitsOverview,
|
||||||
|
}: OverviewConnectProps) => {
|
||||||
|
const { loading, shortUrls } = shortUrlsList;
|
||||||
|
const { loading: loadingTags } = tagsList;
|
||||||
|
const { loading: loadingVisits, visitsCount } = visitsOverview;
|
||||||
|
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listShortUrls({ itemsPerPage: 5, orderBy: { dateCreated: 'DESC' } });
|
||||||
|
listTags();
|
||||||
|
loadVisitsOverview();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-6 col-lg-4">
|
||||||
|
<Card className="overview__card mb-2" body>
|
||||||
|
<CardTitle tag="h5" className="overview__card-title">Visits</CardTitle>
|
||||||
|
<CardText tag="h2">
|
||||||
|
<ForServerVersion minVersion="2.2.0">
|
||||||
|
{loadingVisits ? 'Loading...' : prettify(visitsCount)}
|
||||||
|
</ForServerVersion>
|
||||||
|
<ForServerVersion maxVersion="2.1.*">
|
||||||
|
<small className="text-muted"><i>Shlink 2.2 is needed</i></small>
|
||||||
|
</ForServerVersion>
|
||||||
|
</CardText>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6 col-lg-4">
|
||||||
|
<Card className="overview__card mb-2" body>
|
||||||
|
<CardTitle tag="h5" className="overview__card-title">Short URLs</CardTitle>
|
||||||
|
<CardText tag="h2">
|
||||||
|
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
|
||||||
|
</CardText>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-12 col-lg-4">
|
||||||
|
<Card className="overview__card mb-2" body>
|
||||||
|
<CardTitle tag="h5" className="overview__card-title">Tags</CardTitle>
|
||||||
|
<CardText tag="h2">{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}</CardText>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<span className="d-sm-none">Create a short URL</span>
|
||||||
|
<h5 className="d-none d-sm-inline">Create a short URL</h5>
|
||||||
|
<Link className="float-right" to={`/server/${serverId}/create-short-url`}>Advanced options »</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<CreateShortUrl basicMode />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<span className="d-sm-none">Recently created URLs</span>
|
||||||
|
<h5 className="d-none d-sm-inline">Recently created URLs</h5>
|
||||||
|
<Link className="float-right" to={`/server/${serverId}/list-short-urls/1`}>See all »</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<ShortUrlsTable
|
||||||
|
shortUrlsList={shortUrlsList}
|
||||||
|
selectedServer={selectedServer}
|
||||||
|
className="mb-0"
|
||||||
|
onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tag=${tag}`)}
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, () => 'https://shlink.io/new-visit');
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { isEmpty, values } from 'ramda';
|
import { isEmpty, values } from 'ramda';
|
||||||
import React from 'react';
|
|
||||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { faPlus as plusIcon, faFileDownload as exportIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import ServersExporter from './services/ServersExporter';
|
import ServersExporter from './services/ServersExporter';
|
||||||
import { isServerWithId, SelectedServer, ServersMap } from './data';
|
import { isServerWithId, SelectedServer, ServersMap } from './data';
|
||||||
|
|
||||||
@@ -12,35 +13,43 @@ export interface ServersDropdownProps {
|
|||||||
|
|
||||||
const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, selectedServer }: ServersDropdownProps) => {
|
const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||||
const serversList = values(servers);
|
const serversList = values(servers);
|
||||||
|
const createServerItem = (
|
||||||
|
<DropdownItem tag={Link} to="/server/create">
|
||||||
|
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add server</span>
|
||||||
|
</DropdownItem>
|
||||||
|
);
|
||||||
|
|
||||||
const renderServers = () => {
|
const renderServers = () => {
|
||||||
if (isEmpty(serversList)) {
|
if (isEmpty(serversList)) {
|
||||||
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
|
return createServerItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<>
|
||||||
{serversList.map(({ name, id }) => (
|
{serversList.map(({ name, id }) => (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
key={id}
|
key={id}
|
||||||
tag={Link}
|
tag={Link}
|
||||||
to={`/server/${id}/list-short-urls/1`}
|
to={`/server/${id}`}
|
||||||
active={isServerWithId(selectedServer) && selectedServer.id === id}
|
active={isServerWithId(selectedServer) && selectedServer.id === id}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
))}
|
))}
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
|
{createServerItem}
|
||||||
<DropdownItem className="servers-dropdown__export-item" onClick={async () => serversExporter.exportServers()}>
|
<DropdownItem className="servers-dropdown__export-item" onClick={async () => serversExporter.exportServers()}>
|
||||||
Export servers
|
<FontAwesomeIcon icon={exportIcon} /> <span className="ml-1">Export servers</span>
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</React.Fragment>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UncontrolledDropdown nav inNavbar>
|
<UncontrolledDropdown nav inNavbar>
|
||||||
<DropdownToggle nav caret>Servers</DropdownToggle>
|
<DropdownToggle nav caret>
|
||||||
|
<FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Servers</span>
|
||||||
|
</DropdownToggle>
|
||||||
<DropdownMenu right>{renderServers()}</DropdownMenu>
|
<DropdownMenu right>{renderServers()}</DropdownMenu>
|
||||||
</UncontrolledDropdown>
|
</UncontrolledDropdown>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
@import '../utils/base';
|
||||||
@import '../utils/mixins/vertical-align';
|
@import '../utils/mixins/vertical-align';
|
||||||
|
@import '../utils/mixins/thin-scroll';
|
||||||
|
|
||||||
.servers-list__list-group {
|
.servers-list__list-group.servers-list__list-group {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.servers-list__list-group:not(.servers-list__list-group--embedded) {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
|
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .075);
|
||||||
}
|
}
|
||||||
|
|
||||||
.servers-list__server-item.servers-list__server-item {
|
.servers-list__server-item.servers-list__server-item {
|
||||||
@@ -11,8 +17,29 @@
|
|||||||
padding: .75rem 2.5rem .75rem 1rem;
|
padding: .75rem 2.5rem .75rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.servers-list__server-item:hover {
|
||||||
|
background-color: $lightColor;
|
||||||
|
}
|
||||||
|
|
||||||
.servers-list__server-item-icon {
|
.servers-list__server-item-icon {
|
||||||
@include vertical-align();
|
@include vertical-align();
|
||||||
|
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.servers-list__list-group--embedded.servers-list__list-group--embedded {
|
||||||
|
border-radius: 0;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, .125);
|
||||||
|
|
||||||
|
@media (min-width: $mdMin) {
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
@include thin-scroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
.servers-list__server-item {
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, .125);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,33 +1,35 @@
|
|||||||
import React, { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { ListGroup, ListGroupItem } from 'reactstrap';
|
import { ListGroup, ListGroupItem } from 'reactstrap';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import classNames from 'classnames';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import './ServersListGroup.scss';
|
|
||||||
import { ServerWithId } from './data';
|
import { ServerWithId } from './data';
|
||||||
|
import './ServersListGroup.scss';
|
||||||
|
|
||||||
interface ServersListGroup {
|
interface ServersListGroup {
|
||||||
servers: ServerWithId[];
|
servers: ServerWithId[];
|
||||||
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
|
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
|
||||||
<ListGroupItem tag={Link} to={`/server/${id}/list-short-urls/1`} className="servers-list__server-item">
|
<ListGroupItem tag={Link} to={`/server/${id}`} className="servers-list__server-item">
|
||||||
{name}
|
{name}
|
||||||
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
|
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
|
||||||
</ListGroupItem>
|
</ListGroupItem>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ServersListGroup: FC<ServersListGroup> = ({ servers, children }) => (
|
const ServersListGroup: FC<ServersListGroup> = ({ servers, children, embedded = false }) => (
|
||||||
<React.Fragment>
|
<>
|
||||||
<div className="container">
|
{children && <h5 className="mb-md-3">{children}</h5>}
|
||||||
<h5>{children}</h5>
|
|
||||||
</div>
|
|
||||||
{servers.length > 0 && (
|
{servers.length > 0 && (
|
||||||
<ListGroup className="servers-list__list-group mt-md-3">
|
<ListGroup
|
||||||
|
className={classNames('servers-list__list-group', { 'servers-list__list-group--embedded': embedded })}
|
||||||
|
>
|
||||||
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
|
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default ServersListGroup;
|
export default ServersListGroup;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { versionMatch, Versions } from '../../utils/helpers/version';
|
import { versionMatch, Versions } from '../../utils/helpers/version';
|
||||||
import { isReachableServer, SelectedServer } from '../data';
|
import { isReachableServer, SelectedServer } from '../data';
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ const ForServerVersion: FC<ForServerVersionProps> = ({ minVersion, maxVersion, s
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <React.Fragment>{children}</React.Fragment>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ForServerVersion;
|
export default ForServerVersion;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef, RefObject, ChangeEvent, MutableRefObject } from 'react';
|
import { useRef, RefObject, ChangeEvent, MutableRefObject } from 'react';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
import ServersImporter from '../services/ServersImporter';
|
import ServersImporter from '../services/ServersImporter';
|
||||||
import { ServerData } from '../data';
|
import { ServerData } from '../data';
|
||||||
@@ -33,7 +33,7 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
|
|||||||
.catch(onImportError);
|
.catch(onImportError);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline-secondary mr-2"
|
className="btn btn-outline-secondary mr-2"
|
||||||
@@ -47,7 +47,7 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
|
|||||||
</UncontrolledTooltip>
|
</UncontrolledTooltip>
|
||||||
|
|
||||||
<input type="file" accept="text/csv" className="create-server__csv-select" ref={ref} onChange={onChange} />
|
<input type="file" accept="text/csv" className="create-server__csv-select" ref={ref} onChange={onChange} />
|
||||||
</React.Fragment>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import Message from '../../utils/Message';
|
import Message from '../../utils/Message';
|
||||||
import ServersListGroup from '../ServersListGroup';
|
import ServersListGroup from '../ServersListGroup';
|
||||||
import { DeleteServerButtonProps } from '../DeleteServerButton';
|
import { DeleteServerButtonProps } from '../DeleteServerButton';
|
||||||
import { isServerWithId, SelectedServer, ServersMap } from '../data';
|
import { isServerWithId, SelectedServer, ServersMap } from '../data';
|
||||||
|
import NoMenuLayout from '../../common/NoMenuLayout';
|
||||||
import './ServerError.scss';
|
import './ServerError.scss';
|
||||||
|
|
||||||
interface ServerErrorProps {
|
interface ServerErrorProps {
|
||||||
@@ -14,32 +15,32 @@ interface ServerErrorProps {
|
|||||||
export const ServerError = (DeleteServerButton: FC<DeleteServerButtonProps>): FC<ServerErrorProps> => (
|
export const ServerError = (DeleteServerButton: FC<DeleteServerButtonProps>): FC<ServerErrorProps> => (
|
||||||
{ servers, selectedServer },
|
{ servers, selectedServer },
|
||||||
) => (
|
) => (
|
||||||
<div className="server-error__container flex-column">
|
<NoMenuLayout>
|
||||||
<div className="row w-100 mb-3 mb-md-5">
|
<div className="server-error__container flex-column">
|
||||||
<Message type="error">
|
<Message className="w-100 mb-3 mb-md-5" type="error" fullWidth>
|
||||||
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
|
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
|
||||||
{isServerWithId(selectedServer) && (
|
{isServerWithId(selectedServer) && (
|
||||||
<React.Fragment>
|
<>
|
||||||
<p>Oops! Could not connect to this Shlink server.</p>
|
<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.
|
Make sure you have internet connection, and the server is properly configured and on-line.
|
||||||
</React.Fragment>
|
</>
|
||||||
)}
|
)}
|
||||||
</Message>
|
</Message>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
</NoMenuLayout>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
|
|||||||
3
src/servers/helpers/ServerForm.scss
Normal file
3
src/servers/helpers/ServerForm.scss
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.server-form .form-group:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import React, { FC, useEffect, useState } from 'react';
|
import { FC, ReactNode, useEffect, useState } from 'react';
|
||||||
import { HorizontalFormGroup } from '../../utils/HorizontalFormGroup';
|
import { FormGroupContainer } from '../../utils/FormGroupContainer';
|
||||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||||
import { ServerData } from '../data';
|
import { ServerData } from '../data';
|
||||||
|
import { SimpleCard } from '../../utils/SimpleCard';
|
||||||
|
import './ServerForm.scss';
|
||||||
|
|
||||||
interface ServerFormProps {
|
interface ServerFormProps {
|
||||||
onSubmit: (server: ServerData) => void;
|
onSubmit: (server: ServerData) => void;
|
||||||
initialValues?: ServerData;
|
initialValues?: ServerData;
|
||||||
|
title?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children }) => {
|
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
|
||||||
const [ name, setName ] = useState('');
|
const [ name, setName ] = useState('');
|
||||||
const [ url, setUrl ] = useState('');
|
const [ url, setUrl ] = useState('');
|
||||||
const [ apiKey, setApiKey ] = useState('');
|
const [ apiKey, setApiKey ] = useState('');
|
||||||
@@ -21,10 +24,12 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
|
|||||||
}, [ initialValues ]);
|
}, [ initialValues ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<form className="server-form" onSubmit={handleSubmit}>
|
||||||
<HorizontalFormGroup value={name} onChange={setName}>Name</HorizontalFormGroup>
|
<SimpleCard className="mb-4" title={title}>
|
||||||
<HorizontalFormGroup type="url" value={url} onChange={setUrl}>URL</HorizontalFormGroup>
|
<FormGroupContainer value={name} onChange={setName}>Name</FormGroupContainer>
|
||||||
<HorizontalFormGroup value={apiKey} onChange={setApiKey}>API key</HorizontalFormGroup>
|
<FormGroupContainer type="url" value={url} onChange={setUrl}>URL</FormGroupContainer>
|
||||||
|
<FormGroupContainer value={apiKey} onChange={setApiKey}>API key</FormGroupContainer>
|
||||||
|
</SimpleCard>
|
||||||
|
|
||||||
<div className="text-right">{children}</div>
|
<div className="text-right">{children}</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { FC, useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { RouteComponentProps } from 'react-router';
|
import { RouteComponentProps } from 'react-router';
|
||||||
import Message from '../../utils/Message';
|
import Message from '../../utils/Message';
|
||||||
import { isNotFoundServer, SelectedServer } from '../data';
|
import { isNotFoundServer, SelectedServer } from '../data';
|
||||||
|
import NoMenuLayout from '../../common/NoMenuLayout';
|
||||||
|
|
||||||
interface WithSelectedServerProps extends RouteComponentProps<{ serverId: string }> {
|
interface WithSelectedServerProps extends RouteComponentProps<{ serverId: string }> {
|
||||||
selectServer: (serverId: string) => void;
|
selectServer: (serverId: string) => void;
|
||||||
@@ -18,9 +19,9 @@ export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServ
|
|||||||
|
|
||||||
if (!selectedServer) {
|
if (!selectedServer) {
|
||||||
return (
|
return (
|
||||||
<div className="row">
|
<NoMenuLayout>
|
||||||
<Message loading />
|
<Message loading />
|
||||||
</div>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { FC, useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
|
|
||||||
interface WithoutSelectedServerProps {
|
interface WithoutSelectedServerProps {
|
||||||
resetSelectedServer: Function;
|
resetSelectedServer: Function;
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListPara
|
|||||||
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
||||||
import { SelectedServer } from '../data';
|
import { SelectedServer } from '../data';
|
||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
import { ShlinkHealth } from '../../utils/services/types';
|
import { ShlinkHealth } from '../../api/types';
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { CsvJson } from 'csvjson';
|
import { CsvJson } from 'csvjson';
|
||||||
import { ServerData } from '../data';
|
import { ServerData } from '../data';
|
||||||
|
|
||||||
const CSV_MIME_TYPE = 'text/csv';
|
|
||||||
|
interface CsvFile extends File {
|
||||||
|
type: 'text/csv' | 'text/comma-separated-values' | 'application/csv';
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSV_MIME_TYPES = [ 'text/csv', 'text/comma-separated-values', 'application/csv' ];
|
||||||
|
const isCsv = (file?: File | null): file is CsvFile => !!file && CSV_MIME_TYPES.includes(file.type);
|
||||||
|
|
||||||
export default class ServersImporter {
|
export default class ServersImporter {
|
||||||
public constructor(private readonly csvjson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
public constructor(private readonly csvjson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
||||||
|
|
||||||
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
||||||
if (!file || file.type !== CSV_MIME_TYPE) {
|
if (!isCsv(file)) {
|
||||||
throw new Error('No file provided or file is not a CSV');
|
throw new Error('No file provided or file is not a CSV');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import ForServerVersion from '../helpers/ForServerVersion';
|
|||||||
import { ServerError } from '../helpers/ServerError';
|
import { ServerError } from '../helpers/ServerError';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
|
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
|
||||||
|
import { Overview } from '../Overview';
|
||||||
import ServersImporter from './ServersImporter';
|
import ServersImporter from './ServersImporter';
|
||||||
import ServersExporter from './ServersExporter';
|
import ServersExporter from './ServersExporter';
|
||||||
|
|
||||||
@@ -43,6 +44,12 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
|||||||
bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton');
|
bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton');
|
||||||
bottle.decorator('ServerError', connect([ 'servers', 'selectedServer' ]));
|
bottle.decorator('ServerError', connect([ 'servers', 'selectedServer' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl', 'ForServerVersion');
|
||||||
|
bottle.decorator('Overview', connect(
|
||||||
|
[ 'shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview' ],
|
||||||
|
[ 'listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview' ],
|
||||||
|
));
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
bottle.constant('csvjson', csvjson);
|
bottle.constant('csvjson', csvjson);
|
||||||
bottle.constant('fileReaderFactory', () => new FileReader());
|
bottle.constant('fileReaderFactory', () => new FileReader());
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import { FormGroup, Input } from 'reactstrap';
|
||||||
import { Card, CardBody, CardHeader, FormGroup, Input } from 'reactstrap';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import { Settings } from './reducers/settings';
|
import { Settings } from './reducers/settings';
|
||||||
|
|
||||||
interface RealTimeUpdatesProps {
|
interface RealTimeUpdatesProps {
|
||||||
@@ -15,39 +15,36 @@ const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
|
|||||||
const RealTimeUpdates = (
|
const RealTimeUpdates = (
|
||||||
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
|
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
|
||||||
) => (
|
) => (
|
||||||
<Card>
|
<SimpleCard title="Real-time updates">
|
||||||
<CardHeader>Real-time updates</CardHeader>
|
<FormGroup>
|
||||||
<CardBody>
|
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
|
||||||
<FormGroup>
|
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
|
||||||
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
|
</ToggleSwitch>
|
||||||
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
|
</FormGroup>
|
||||||
</ToggleSwitch>
|
<FormGroup className="mb-0">
|
||||||
</FormGroup>
|
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
|
||||||
<FormGroup className="mb-0">
|
Real-time updates frequency (in minutes):
|
||||||
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
|
</label>
|
||||||
Real-time updates frequency (in minutes):
|
<Input
|
||||||
</label>
|
type="number"
|
||||||
<Input
|
min={0}
|
||||||
type="number"
|
placeholder="Immediate"
|
||||||
min={0}
|
disabled={!realTimeUpdates.enabled}
|
||||||
placeholder="Immediate"
|
value={intervalValue(realTimeUpdates.interval)}
|
||||||
disabled={!realTimeUpdates.enabled}
|
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
|
||||||
value={intervalValue(realTimeUpdates.interval)}
|
/>
|
||||||
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
|
{realTimeUpdates.enabled && (
|
||||||
/>
|
<small className="form-text text-muted">
|
||||||
{realTimeUpdates.enabled && (
|
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
|
||||||
<small className="form-text text-muted">
|
<span>
|
||||||
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
|
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
|
||||||
<span>
|
</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>
|
||||||
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
|
)}
|
||||||
</small>
|
</FormGroup>
|
||||||
)}
|
</SimpleCard>
|
||||||
</FormGroup>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export default RealTimeUpdates;
|
export default RealTimeUpdates;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import NoMenuLayout from '../common/NoMenuLayout';
|
import NoMenuLayout from '../common/NoMenuLayout';
|
||||||
|
|
||||||
const Settings = (RealTimeUpdates: FC) => () => (
|
const Settings = (RealTimeUpdates: FC) => () => (
|
||||||
|
|||||||
6
src/short-urls/CreateShortUrl.scss
Normal file
6
src/short-urls/CreateShortUrl.scss
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@import '../utils/base';
|
||||||
|
|
||||||
|
.create-short-url .form-group:last-child,
|
||||||
|
.create-short-url p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
@@ -1,32 +1,36 @@
|
|||||||
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 { isEmpty, pipe, replace, trim } from 'ramda';
|
||||||
import React, { FC, useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
import { Collapse, FormGroup, Input } from 'reactstrap';
|
import { Button, FormGroup, Input } from 'reactstrap';
|
||||||
import { InputType } from 'reactstrap/lib/Input';
|
import { InputType } from 'reactstrap/lib/Input';
|
||||||
import * as m from 'moment';
|
import * as m from 'moment';
|
||||||
import DateInput, { DateInputProps } from '../utils/DateInput';
|
import DateInput, { DateInputProps } from '../utils/DateInput';
|
||||||
import Checkbox from '../utils/Checkbox';
|
import Checkbox from '../utils/Checkbox';
|
||||||
import { versionMatch, Versions } from '../utils/helpers/version';
|
import { versionMatch, Versions } from '../utils/helpers/version';
|
||||||
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
|
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
|
||||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||||
import { formatIsoDate } from '../utils/helpers/date';
|
import { formatIsoDate } from '../utils/helpers/date';
|
||||||
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||||
|
import { DomainSelectorProps } from '../domains/DomainSelector';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import { ShortUrlData } from './data';
|
import { ShortUrlData } from './data';
|
||||||
import { ShortUrlCreation } from './reducers/shortUrlCreation';
|
import { ShortUrlCreation } from './reducers/shortUrlCreation';
|
||||||
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
||||||
import { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
|
import { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
|
||||||
|
import './CreateShortUrl.scss';
|
||||||
|
|
||||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
export interface CreateShortUrlProps {
|
||||||
|
basicMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface CreateShortUrlProps {
|
interface CreateShortUrlConnectProps extends CreateShortUrlProps {
|
||||||
shortUrlCreationResult: ShortUrlCreation;
|
shortUrlCreationResult: ShortUrlCreation;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
createShortUrl: Function;
|
createShortUrl: (data: ShortUrlData) => Promise<void>;
|
||||||
resetCreateShortUrl: () => void;
|
resetCreateShortUrl: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||||
|
|
||||||
const initialState: ShortUrlData = {
|
const initialState: ShortUrlData = {
|
||||||
longUrl: '',
|
longUrl: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -37,6 +41,7 @@ const initialState: ShortUrlData = {
|
|||||||
validUntil: undefined,
|
validUntil: undefined,
|
||||||
maxVisits: undefined,
|
maxVisits: undefined,
|
||||||
findIfExists: false,
|
findIfExists: false,
|
||||||
|
validateUrl: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits';
|
type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits';
|
||||||
@@ -46,17 +51,23 @@ const CreateShortUrl = (
|
|||||||
TagsSelector: FC<TagsSelectorProps>,
|
TagsSelector: FC<TagsSelectorProps>,
|
||||||
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
|
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
|
||||||
ForServerVersion: FC<Versions>,
|
ForServerVersion: FC<Versions>,
|
||||||
) => ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }: CreateShortUrlProps) => {
|
DomainSelector: FC<DomainSelectorProps>,
|
||||||
|
) => ({
|
||||||
|
createShortUrl,
|
||||||
|
shortUrlCreationResult,
|
||||||
|
resetCreateShortUrl,
|
||||||
|
selectedServer,
|
||||||
|
basicMode = false,
|
||||||
|
}: CreateShortUrlConnectProps) => {
|
||||||
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
|
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
|
||||||
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
|
|
||||||
|
|
||||||
const changeTags = (tags: string[]) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
|
const changeTags = (tags: string[]) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
|
||||||
const reset = () => setShortUrlCreation(initialState);
|
const reset = () => setShortUrlCreation(initialState);
|
||||||
const save = handleEventPreventingDefault(() => {
|
const save = handleEventPreventingDefault(() => {
|
||||||
const shortUrlData = {
|
const shortUrlData = {
|
||||||
...shortUrlCreation,
|
...shortUrlCreation,
|
||||||
validSince: formatIsoDate(shortUrlCreation.validSince),
|
validSince: formatIsoDate(shortUrlCreation.validSince) ?? undefined,
|
||||||
validUntil: formatIsoDate(shortUrlCreation.validUntil),
|
validUntil: formatIsoDate(shortUrlCreation.validUntil) ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
createShortUrl(shortUrlData).then(reset).catch(() => {});
|
createShortUrl(shortUrlData).then(reset).catch(() => {});
|
||||||
@@ -84,93 +95,119 @@ const CreateShortUrl = (
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
const basicComponents = (
|
||||||
const currentServerVersion = isReachableServer(selectedServer) ? selectedServer.version : '';
|
<>
|
||||||
const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
|
<FormGroup>
|
||||||
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
|
<Input
|
||||||
|
bsSize="lg"
|
||||||
return (
|
|
||||||
<form onSubmit={save}>
|
|
||||||
<div className="form-group">
|
|
||||||
<input
|
|
||||||
className="form-control form-control-lg"
|
|
||||||
type="url"
|
type="url"
|
||||||
placeholder="Insert the URL to be shortened"
|
placeholder="URL to be shortened"
|
||||||
required
|
required
|
||||||
value={shortUrlCreation.longUrl}
|
value={shortUrlCreation.longUrl}
|
||||||
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, longUrl: e.target.value })}
|
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, longUrl: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormGroup>
|
||||||
|
|
||||||
<Collapse isOpen={moreOptionsVisible}>
|
<FormGroup>
|
||||||
<div className="form-group">
|
<TagsSelector tags={shortUrlCreation.tags ?? []} onChange={changeTags} />
|
||||||
<TagsSelector tags={shortUrlCreation.tags ?? []} onChange={changeTags} />
|
</FormGroup>
|
||||||
</div>
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
<div className="row">
|
const currentServerVersion = isReachableServer(selectedServer) ? selectedServer.version : '';
|
||||||
<div className="col-sm-4">
|
const showDomainSelector = versionMatch(currentServerVersion, { minVersion: '2.4.0' });
|
||||||
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
|
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
|
||||||
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">
|
return (
|
||||||
<div className="col-sm-4">
|
<form className="create-short-url" onSubmit={save}>
|
||||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
{basicMode && basicComponents}
|
||||||
</div>
|
{!basicMode && (
|
||||||
<div className="col-sm-4">
|
<>
|
||||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil as m.Moment | undefined })}
|
<SimpleCard title="Basic options" className="mb-3">
|
||||||
</div>
|
{basicComponents}
|
||||||
<div className="col-sm-4">
|
</SimpleCard>
|
||||||
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince as m.Moment | undefined })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ForServerVersion minVersion="1.16.0">
|
<div className="row">
|
||||||
<div className="mb-4 text-right">
|
<div className="col-sm-6 mb-3">
|
||||||
<Checkbox
|
<SimpleCard title="Customize the short URL">
|
||||||
className="mr-2"
|
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
|
||||||
checked={shortUrlCreation.findIfExists}
|
disabled: hasValue(shortUrlCreation.shortCodeLength),
|
||||||
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
|
})}
|
||||||
>
|
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
||||||
Use existing URL if found
|
min: 4,
|
||||||
</Checkbox>
|
disabled: disableShortCodeLength || hasValue(shortUrlCreation.customSlug),
|
||||||
<UseExistingIfFoundInfoIcon />
|
...disableShortCodeLength && {
|
||||||
</div>
|
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
|
||||||
</ForServerVersion>
|
},
|
||||||
</Collapse>
|
})}
|
||||||
|
{!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text')}
|
||||||
|
{showDomainSelector && (
|
||||||
|
<FormGroup>
|
||||||
|
<DomainSelector
|
||||||
|
value={shortUrlCreation.domain}
|
||||||
|
onChange={(domain?: string) => setShortUrlCreation({ ...shortUrlCreation, domain })}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
</SimpleCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="col-sm-6 mb-3">
|
||||||
<button type="button" className="btn btn-outline-secondary" onClick={toggleMoreOptionsVisible}>
|
<SimpleCard title="Limit access to the short URL">
|
||||||
<FontAwesomeIcon icon={moreOptionsVisible ? upIcon : downIcon} />
|
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||||
|
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil as m.Moment | undefined })}
|
||||||
{moreOptionsVisible ? 'Less' : 'More'} options
|
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince as m.Moment | undefined })}
|
||||||
</button>
|
</SimpleCard>
|
||||||
<button
|
</div>
|
||||||
className="btn btn-outline-primary float-right"
|
</div>
|
||||||
|
|
||||||
|
<SimpleCard title="Extra validations" className="mb-3">
|
||||||
|
<p>
|
||||||
|
Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all
|
||||||
|
provided data.
|
||||||
|
</p>
|
||||||
|
<ForServerVersion minVersion="2.4.0">
|
||||||
|
<p>
|
||||||
|
<Checkbox
|
||||||
|
inline
|
||||||
|
checked={shortUrlCreation.validateUrl}
|
||||||
|
onChange={(validateUrl) => setShortUrlCreation({ ...shortUrlCreation, validateUrl })}
|
||||||
|
>
|
||||||
|
Validate URL
|
||||||
|
</Checkbox>
|
||||||
|
</p>
|
||||||
|
</ForServerVersion>
|
||||||
|
<p>
|
||||||
|
<Checkbox
|
||||||
|
inline
|
||||||
|
className="mr-2"
|
||||||
|
checked={shortUrlCreation.findIfExists}
|
||||||
|
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
|
||||||
|
>
|
||||||
|
Use existing URL if found
|
||||||
|
</Checkbox>
|
||||||
|
<UseExistingIfFoundInfoIcon />
|
||||||
|
</p>
|
||||||
|
</SimpleCard>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Button
|
||||||
|
outline
|
||||||
|
color="primary"
|
||||||
disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)}
|
disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)}
|
||||||
|
className="btn-xs-block"
|
||||||
>
|
>
|
||||||
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
|
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
|
<CreateShortUrlResult
|
||||||
|
{...shortUrlCreationResult}
|
||||||
|
resetCreateShortUrl={resetCreateShortUrl}
|
||||||
|
canBeClosed={basicMode}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.short-urls-paginator {
|
.short-urls-paginator {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: rgba(white, .8);
|
background-color: rgba(255, 255, 255, .5);
|
||||||
padding: .75rem 0;
|
padding: .75rem 0;
|
||||||
border-top: 1px solid rgba(black, .125);
|
border-top: 1px solid rgba(black, .125);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||||
import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination';
|
import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination';
|
||||||
import { ShlinkPaginator } from '../utils/services/types';
|
import { ShlinkPaginator } from '../api/types';
|
||||||
import './Paginator.scss';
|
import './Paginator.scss';
|
||||||
|
|
||||||
interface PaginatorProps {
|
interface PaginatorProps {
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import React, { FC } from 'react';
|
|
||||||
import { isEmpty, pipe } from 'ramda';
|
import { isEmpty, pipe } from 'ramda';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import Tag from '../tags/helpers/Tag';
|
import Tag from '../tags/helpers/Tag';
|
||||||
import DateRangeRow from '../utils/DateRangeRow';
|
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||||
import { formatDate } from '../utils/helpers/date';
|
import { formatIsoDate } from '../utils/helpers/date';
|
||||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||||
import { Versions } from '../utils/helpers/version';
|
import { DateRange } from '../utils/dates/types';
|
||||||
import { ShortUrlsListParams } from './reducers/shortUrlsListParams';
|
import { ShortUrlsListParams } from './reducers/shortUrlsListParams';
|
||||||
import './SearchBar.scss';
|
import './SearchBar.scss';
|
||||||
|
|
||||||
@@ -19,13 +18,14 @@ interface SearchBarProps {
|
|||||||
|
|
||||||
const dateOrNull = (date?: string) => date ? moment(date) : null;
|
const dateOrNull = (date?: string) => date ? moment(date) : null;
|
||||||
|
|
||||||
const SearchBar = (colorGenerator: ColorGenerator, ForServerVersion: FC<Versions>) => (
|
const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrlsListParams }: SearchBarProps) => {
|
||||||
{ listShortUrls, shortUrlsListParams }: SearchBarProps,
|
|
||||||
) => {
|
|
||||||
const selectedTags = shortUrlsListParams.tags ?? [];
|
const selectedTags = shortUrlsListParams.tags ?? [];
|
||||||
const setDate = (dateName: 'startDate' | 'endDate') => pipe(
|
const setDates = pipe(
|
||||||
formatDate(),
|
({ startDate, endDate }: DateRange) => ({
|
||||||
(date) => listShortUrls({ ...shortUrlsListParams, [dateName]: date }),
|
startDate: formatIsoDate(startDate) ?? undefined,
|
||||||
|
endDate: formatIsoDate(endDate) ?? undefined,
|
||||||
|
}),
|
||||||
|
(dates) => listShortUrls({ ...shortUrlsListParams, ...dates }),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,20 +36,20 @@ const SearchBar = (colorGenerator: ColorGenerator, ForServerVersion: FC<Versions
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ForServerVersion minVersion="1.21.0">
|
<div className="mt-3">
|
||||||
<div className="mt-3">
|
<div className="row">
|
||||||
<div className="row">
|
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
|
||||||
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
|
<DateRangeSelector
|
||||||
<DateRangeRow
|
defaultText="All short URLs"
|
||||||
startDate={dateOrNull(shortUrlsListParams.startDate)}
|
initialDateRange={{
|
||||||
endDate={dateOrNull(shortUrlsListParams.endDate)}
|
startDate: dateOrNull(shortUrlsListParams.startDate),
|
||||||
onStartDateChange={setDate('startDate')}
|
endDate: dateOrNull(shortUrlsListParams.endDate),
|
||||||
onEndDateChange={setDate('endDate')}
|
}}
|
||||||
/>
|
onDatesChange={setDates}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ForServerVersion>
|
</div>
|
||||||
|
|
||||||
{!isEmpty(selectedTags) && (
|
{!isEmpty(selectedTags) && (
|
||||||
<h4 className="search-bar__selected-tag mt-3">
|
<h4 className="search-bar__selected-tag mt-3">
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import React, { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { ShlinkShortUrlsResponse } from '../utils/services/types';
|
import { ShortUrlsListProps } from './ShortUrlsList';
|
||||||
import Paginator from './Paginator';
|
|
||||||
import { ShortUrlsListProps, WithList } from './ShortUrlsList';
|
|
||||||
|
|
||||||
interface ShortUrlsProps extends ShortUrlsListProps {
|
const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps>) => (props: ShortUrlsListProps) => {
|
||||||
shortUrlsList?: ShlinkShortUrlsResponse;
|
const { match } = props;
|
||||||
}
|
|
||||||
|
|
||||||
const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps & WithList>) => (props: ShortUrlsProps) => {
|
|
||||||
const { match, shortUrlsList } = props;
|
|
||||||
const { page = '1', serverId = '' } = match?.params ?? {};
|
const { page = '1', serverId = '' } = match?.params ?? {};
|
||||||
const { data = [], pagination } = shortUrlsList ?? {};
|
|
||||||
const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`);
|
const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`);
|
||||||
|
|
||||||
// Using a key on a component makes react to create a new instance every time the key changes
|
// Using a key on a component makes react to create a new instance every time the key changes
|
||||||
@@ -20,13 +13,10 @@ const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps & WithLis
|
|||||||
}, [ serverId, page ]);
|
}, [ serverId, page ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<>
|
||||||
<div className="form-group"><SearchBar /></div>
|
<div className="form-group"><SearchBar /></div>
|
||||||
<div>
|
<ShortUrlsList {...props} key={urlsListKey} />
|
||||||
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
|
</>
|
||||||
<Paginator paginator={pagination} serverId={serverId} />
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,3 @@
|
|||||||
@import '../utils/base';
|
|
||||||
|
|
||||||
.short-urls-list__header {
|
|
||||||
@media (max-width: $smMax) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.short-urls-list__header--with-action {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.short-urls-list__header-icon {
|
.short-urls-list__header-icon {
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.short-urls-list__header-cell--with-action {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { head, isEmpty, keys, values } from 'ramda';
|
import { head, keys, values } from 'ramda';
|
||||||
import React, { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import qs from 'qs';
|
|
||||||
import { RouteComponentProps } from 'react-router';
|
import { RouteComponentProps } from 'react-router';
|
||||||
|
import { Card } from 'reactstrap';
|
||||||
import SortingDropdown from '../utils/SortingDropdown';
|
import SortingDropdown from '../utils/SortingDropdown';
|
||||||
import { determineOrderDir, OrderDir } from '../utils/utils';
|
import { determineOrderDir, OrderDir } from '../utils/utils';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
|
import { parseQuery } from '../utils/helpers/query';
|
||||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||||
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
|
|
||||||
import { ShortUrl } from './data';
|
|
||||||
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
|
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
|
||||||
|
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
||||||
|
import Paginator from './Paginator';
|
||||||
import './ShortUrlsList.scss';
|
import './ShortUrlsList.scss';
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
@@ -19,33 +20,29 @@ interface RouteParams {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WithList {
|
export interface ShortUrlsListProps extends RouteComponentProps<RouteParams> {
|
||||||
shortUrlsList: ShortUrl[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShortUrlsListProps extends ShortUrlsListState, RouteComponentProps<RouteParams> {
|
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
|
shortUrlsList: ShortUrlsListState;
|
||||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
listShortUrls: (params: ShortUrlsListParams) => void;
|
||||||
shortUrlsListParams: ShortUrlsListParams;
|
shortUrlsListParams: ShortUrlsListParams;
|
||||||
resetShortUrlParams: () => void;
|
resetShortUrlParams: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => boundToMercureHub(({
|
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercureHub(({
|
||||||
listShortUrls,
|
listShortUrls,
|
||||||
resetShortUrlParams,
|
resetShortUrlParams,
|
||||||
shortUrlsListParams,
|
shortUrlsListParams,
|
||||||
match,
|
match,
|
||||||
location,
|
location,
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
shortUrlsList,
|
shortUrlsList,
|
||||||
selectedServer,
|
selectedServer,
|
||||||
}: ShortUrlsListProps & WithList) => {
|
}: ShortUrlsListProps) => {
|
||||||
const { orderBy } = shortUrlsListParams;
|
const { orderBy } = shortUrlsListParams;
|
||||||
const [ order, setOrder ] = useState<{ orderField?: OrderableFields; orderDir?: OrderDir }>({
|
const [ order, setOrder ] = useState<{ orderField?: OrderableFields; orderDir?: OrderDir }>({
|
||||||
orderField: orderBy && (head(keys(orderBy)) as OrderableFields),
|
orderField: orderBy && (head(keys(orderBy)) as OrderableFields),
|
||||||
orderDir: orderBy && head(values(orderBy)),
|
orderDir: orderBy && head(values(orderBy)),
|
||||||
});
|
});
|
||||||
|
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
||||||
const refreshList = (extraParams: ShortUrlsListParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
|
const refreshList = (extraParams: ShortUrlsListParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
|
||||||
const handleOrderBy = (orderField?: OrderableFields, orderDir?: OrderDir) => {
|
const handleOrderBy = (orderField?: OrderableFields, orderDir?: OrderDir) => {
|
||||||
setOrder({ orderField, orderDir });
|
setOrder({ orderField, orderDir });
|
||||||
@@ -69,46 +66,19 @@ const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => boundToMercureHub
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const renderShortUrls = () => {
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="text-center table-danger">Something went wrong while loading short URLs :(</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <tr><td colSpan={6} className="text-center">Loading...</td></tr>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!loading && isEmpty(shortUrlsList)) {
|
|
||||||
return <tr><td colSpan={6} className="text-center">No results found</td></tr>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return shortUrlsList.map((shortUrl) => (
|
|
||||||
<ShortUrlsRow
|
|
||||||
key={shortUrl.shortUrl}
|
|
||||||
shortUrl={shortUrl}
|
|
||||||
selectedServer={selectedServer}
|
|
||||||
refreshList={refreshList}
|
|
||||||
shortUrlsListParams={shortUrlsListParams}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
|
const { tag } = parseQuery<{ tag?: string }>(location.search);
|
||||||
const tags = query.tag ? [ query.tag as string ] : shortUrlsListParams.tags;
|
const tags = tag ? [ tag ] : shortUrlsListParams.tags;
|
||||||
|
|
||||||
refreshList({ page: match.params.page, tags });
|
refreshList({ page: match.params.page, tags, itemsPerPage: undefined });
|
||||||
|
|
||||||
return resetShortUrlParams;
|
return resetShortUrlParams;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<>
|
||||||
<div className="d-block d-md-none mb-3">
|
<div className="d-block d-lg-none mb-3">
|
||||||
<SortingDropdown
|
<SortingDropdown
|
||||||
items={SORTABLE_FIELDS}
|
items={SORTABLE_FIELDS}
|
||||||
orderField={order.orderField}
|
orderField={order.orderField}
|
||||||
@@ -116,45 +86,17 @@ const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => boundToMercureHub
|
|||||||
onChange={handleOrderBy}
|
onChange={handleOrderBy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<table className="table table-striped table-hover">
|
<Card body className="pb-1">
|
||||||
<thead className="short-urls-list__header">
|
<ShortUrlsTable
|
||||||
<tr>
|
orderByColumn={orderByColumn}
|
||||||
<th
|
renderOrderIcon={renderOrderIcon}
|
||||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
selectedServer={selectedServer}
|
||||||
onClick={orderByColumn('dateCreated')}
|
shortUrlsList={shortUrlsList}
|
||||||
>
|
onTagClick={(tag) => refreshList({ tags: [ ...shortUrlsListParams.tags ?? [], tag ] })}
|
||||||
{renderOrderIcon('dateCreated')}
|
/>
|
||||||
Created at
|
<Paginator paginator={pagination} serverId={isReachableServer(selectedServer) ? selectedServer.id : ''} />
|
||||||
</th>
|
</Card>
|
||||||
<th
|
</>
|
||||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
|
||||||
onClick={orderByColumn('shortCode')}
|
|
||||||
>
|
|
||||||
{renderOrderIcon('shortCode')}
|
|
||||||
Short URL
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
|
||||||
onClick={orderByColumn('longUrl')}
|
|
||||||
>
|
|
||||||
{renderOrderIcon('longUrl')}
|
|
||||||
Long URL
|
|
||||||
</th>
|
|
||||||
<th className="short-urls-list__header-cell">Tags</th>
|
|
||||||
<th
|
|
||||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
|
||||||
onClick={orderByColumn('visits')}
|
|
||||||
>
|
|
||||||
<span className="indivisible">{renderOrderIcon('visits')} Visits</span>
|
|
||||||
</th>
|
|
||||||
<th className="short-urls-list__header-cell"> </th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{renderShortUrls()}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
);
|
||||||
}, () => 'https://shlink.io/new-visit');
|
}, () => 'https://shlink.io/new-visit');
|
||||||
|
|
||||||
|
|||||||
11
src/short-urls/ShortUrlsTable.scss
Normal file
11
src/short-urls/ShortUrlsTable.scss
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
@import '../utils/base';
|
||||||
|
|
||||||
|
.short-urls-table__header {
|
||||||
|
@media (max-width: $responsiveTableBreakpoint) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-urls-table__header-cell--with-action {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
88
src/short-urls/ShortUrlsTable.tsx
Normal file
88
src/short-urls/ShortUrlsTable.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { FC, ReactNode } from 'react';
|
||||||
|
import { isEmpty } from 'ramda';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { SelectedServer } from '../servers/data';
|
||||||
|
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||||
|
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
|
||||||
|
import { OrderableFields } from './reducers/shortUrlsListParams';
|
||||||
|
import './ShortUrlsTable.scss';
|
||||||
|
|
||||||
|
export interface ShortUrlsTableProps {
|
||||||
|
orderByColumn?: (column: OrderableFields) => () => void;
|
||||||
|
renderOrderIcon?: (column: OrderableFields) => ReactNode;
|
||||||
|
shortUrlsList: ShortUrlsListState;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
onTagClick?: (tag: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||||
|
orderByColumn,
|
||||||
|
renderOrderIcon,
|
||||||
|
shortUrlsList,
|
||||||
|
onTagClick,
|
||||||
|
selectedServer,
|
||||||
|
className,
|
||||||
|
}: ShortUrlsTableProps) => {
|
||||||
|
const { error, loading, shortUrls } = shortUrlsList;
|
||||||
|
const orderableColumnsClasses = classNames('short-urls-table__header-cell', {
|
||||||
|
'short-urls-table__header-cell--with-action': !!orderByColumn,
|
||||||
|
});
|
||||||
|
const tableClasses = classNames('table table-hover', className);
|
||||||
|
|
||||||
|
const renderShortUrls = () => {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-center table-danger">Something went wrong while loading short URLs :(</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <tr><td colSpan={6} className="text-center">Loading...</td></tr>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loading && isEmpty(shortUrls?.data)) {
|
||||||
|
return <tr><td colSpan={6} className="text-center">No results found</td></tr>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shortUrls?.data.map((shortUrl) => (
|
||||||
|
<ShortUrlsRow
|
||||||
|
key={shortUrl.shortUrl}
|
||||||
|
shortUrl={shortUrl}
|
||||||
|
selectedServer={selectedServer}
|
||||||
|
onTagClick={onTagClick}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className={tableClasses}>
|
||||||
|
<thead className="short-urls-table__header">
|
||||||
|
<tr>
|
||||||
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
|
||||||
|
{renderOrderIcon?.('dateCreated')}
|
||||||
|
Created at
|
||||||
|
</th>
|
||||||
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
|
||||||
|
{renderOrderIcon?.('shortCode')}
|
||||||
|
Short URL
|
||||||
|
</th>
|
||||||
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}>
|
||||||
|
{renderOrderIcon?.('longUrl')}
|
||||||
|
Long URL
|
||||||
|
</th>
|
||||||
|
<th className="short-urls-table__header-cell">Tags</th>
|
||||||
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
|
||||||
|
<span className="indivisible">{renderOrderIcon?.('visits')} Visits</span>
|
||||||
|
</th>
|
||||||
|
<th className="short-urls-table__header-cell"> </th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{renderShortUrls()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
||||||
@@ -41,12 +40,12 @@ const UseExistingIfFoundInfoIcon = () => {
|
|||||||
const [ isModalOpen, toggleModal ] = useToggle();
|
const [ isModalOpen, toggleModal ] = useToggle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<>
|
||||||
<span title="What does this mean?">
|
<span title="What does this mean?">
|
||||||
<FontAwesomeIcon icon={infoIcon} style={{ cursor: 'pointer' }} onClick={toggleModal} />
|
<FontAwesomeIcon icon={infoIcon} style={{ cursor: 'pointer' }} onClick={toggleModal} />
|
||||||
</span>
|
</span>
|
||||||
<InfoModal isOpen={isModalOpen} toggle={toggleModal} />
|
<InfoModal isOpen={isModalOpen} toggle={toggleModal} />
|
||||||
</React.Fragment>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface ShortUrlData {
|
|||||||
validUntil?: m.Moment | string;
|
validUntil?: m.Moment | string;
|
||||||
maxVisits?: number;
|
maxVisits?: number;
|
||||||
findIfExists?: boolean;
|
findIfExists?: boolean;
|
||||||
|
validateUrl?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShortUrl {
|
export interface ShortUrl {
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
||||||
|
import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { isNil } from 'ramda';
|
import { isNil } from 'ramda';
|
||||||
import React, { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||||
import { Card, CardBody, Tooltip } from 'reactstrap';
|
import { Tooltip } from 'reactstrap';
|
||||||
import { ShortUrlCreation } from '../reducers/shortUrlCreation';
|
import { ShortUrlCreation } from '../reducers/shortUrlCreation';
|
||||||
import { StateFlagTimeout } from '../../utils/helpers/hooks';
|
import { StateFlagTimeout } from '../../utils/helpers/hooks';
|
||||||
|
import { Result } from '../../utils/Result';
|
||||||
import './CreateShortUrlResult.scss';
|
import './CreateShortUrlResult.scss';
|
||||||
|
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||||
|
|
||||||
export interface CreateShortUrlResultProps extends ShortUrlCreation {
|
export interface CreateShortUrlResultProps extends ShortUrlCreation {
|
||||||
resetCreateShortUrl: () => void;
|
resetCreateShortUrl: () => void;
|
||||||
|
canBeClosed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
|
const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
|
||||||
{ error, result, resetCreateShortUrl }: CreateShortUrlResultProps,
|
{ error, errorData, result, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps,
|
||||||
) => {
|
) => {
|
||||||
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout();
|
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout();
|
||||||
|
|
||||||
@@ -23,9 +27,10 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Card body color="danger" inverse className="bg-danger mt-3">
|
<Result type="error" className="mt-3">
|
||||||
An error occurred while creating the URL :(
|
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />}
|
||||||
</Card>
|
<ShlinkApiError errorData={errorData} fallbackMessage="An error occurred while creating the URL :(" />
|
||||||
|
</Result>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,25 +41,24 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
|
|||||||
const { shortUrl } = result;
|
const { shortUrl } = result;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card inverse className="bg-main mt-3">
|
<Result type="success" className="mt-3">
|
||||||
<CardBody>
|
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />}
|
||||||
<b>Great!</b> The short URL is <b>{shortUrl}</b>
|
<b>Great!</b> The short URL is <b>{shortUrl}</b>
|
||||||
|
|
||||||
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
|
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-light btn-sm create-short-url-result__copy-btn"
|
className="btn btn-light btn-sm create-short-url-result__copy-btn"
|
||||||
id="copyBtn"
|
id="copyBtn"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={copyIcon} /> Copy
|
<FontAwesomeIcon icon={copyIcon} /> Copy
|
||||||
</button>
|
</button>
|
||||||
</CopyToClipboard>
|
</CopyToClipboard>
|
||||||
|
|
||||||
<Tooltip placement="left" isOpen={showCopyTooltip} target="copyBtn">
|
<Tooltip placement="left" isOpen={showCopyTooltip} target="copyBtn">
|
||||||
Copied!
|
Copied!
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</CardBody>
|
</Result>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import { identity, pipe } from 'ramda';
|
import { identity, pipe } from 'ramda';
|
||||||
import { ShortUrlDeletion } from '../reducers/shortUrlDeletion';
|
import { ShortUrlDeletion } from '../reducers/shortUrlDeletion';
|
||||||
import { ShortUrlModalProps } from '../data';
|
import { ShortUrlModalProps } from '../data';
|
||||||
import { handleEventPreventingDefault, OptionalString } from '../../utils/utils';
|
import { handleEventPreventingDefault, OptionalString } from '../../utils/utils';
|
||||||
|
import { Result } from '../../utils/Result';
|
||||||
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
|
import { isInvalidDeletionError } from '../../api/utils';
|
||||||
|
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||||
|
|
||||||
interface DeleteShortUrlModalConnectProps extends ShortUrlModalProps {
|
interface DeleteShortUrlModalConnectProps extends ShortUrlModalProps {
|
||||||
shortUrlDeletion: ShortUrlDeletion;
|
shortUrlDeletion: ShortUrlDeletion;
|
||||||
@@ -21,9 +22,6 @@ const DeleteShortUrlModal = (
|
|||||||
useEffect(() => resetDeleteShortUrl, []);
|
useEffect(() => resetDeleteShortUrl, []);
|
||||||
|
|
||||||
const { error, errorData } = shortUrlDeletion;
|
const { error, errorData } = shortUrlDeletion;
|
||||||
const errorCode = error && (errorData?.type || errorData?.error);
|
|
||||||
const hasThresholdError = errorCode === THRESHOLD_REACHED;
|
|
||||||
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
|
|
||||||
const close = pipe(resetDeleteShortUrl, toggle);
|
const close = pipe(resetDeleteShortUrl, toggle);
|
||||||
const handleDeleteUrl = handleEventPreventingDefault(() => {
|
const handleDeleteUrl = handleEventPreventingDefault(() => {
|
||||||
const { shortCode, domain } = shortUrl;
|
const { shortCode, domain } = shortUrl;
|
||||||
@@ -42,25 +40,20 @@ const DeleteShortUrlModal = (
|
|||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
|
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
|
||||||
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
|
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
|
||||||
|
<p>Write <b>{shortUrl.shortCode}</b> to confirm deletion.</p>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
placeholder="Insert the short code of the URL"
|
placeholder={`Insert the short code (${shortUrl.shortCode})`}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hasThresholdError && (
|
{error && (
|
||||||
<div className="p-2 mt-2 bg-warning text-center">
|
<Result type={isInvalidDeletionError(errorData) ? 'warning' : 'error'} small className="mt-2">
|
||||||
{errorData?.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`}
|
<ShlinkApiError errorData={errorData} fallbackMessage="Something went wrong while deleting the URL :(" />
|
||||||
{!errorData?.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
|
</Result>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasErrorOtherThanThreshold && (
|
|
||||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
|
||||||
Something went wrong while deleting the URL :(
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { ChangeEvent, useState } from 'react';
|
import { ChangeEvent, useState } from 'react';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, UncontrolledTooltip } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, UncontrolledTooltip } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
@@ -10,6 +10,8 @@ import DateInput from '../../utils/DateInput';
|
|||||||
import { formatIsoDate } from '../../utils/helpers/date';
|
import { formatIsoDate } from '../../utils/helpers/date';
|
||||||
import { ShortUrl, ShortUrlMeta, ShortUrlModalProps } from '../data';
|
import { ShortUrl, ShortUrlMeta, ShortUrlModalProps } from '../data';
|
||||||
import { handleEventPreventingDefault, Nullable, OptionalString } from '../../utils/utils';
|
import { handleEventPreventingDefault, Nullable, OptionalString } from '../../utils/utils';
|
||||||
|
import { Result } from '../../utils/Result';
|
||||||
|
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||||
|
|
||||||
interface EditMetaModalConnectProps extends ShortUrlModalProps {
|
interface EditMetaModalConnectProps extends ShortUrlModalProps {
|
||||||
shortUrlMeta: ShortUrlMetaEdition;
|
shortUrlMeta: ShortUrlMetaEdition;
|
||||||
@@ -17,19 +19,19 @@ interface EditMetaModalConnectProps extends ShortUrlModalProps {
|
|||||||
editShortUrlMeta: (shortCode: string, domain: OptionalString, meta: Nullable<ShortUrlMeta>) => Promise<void>;
|
editShortUrlMeta: (shortCode: string, domain: OptionalString, meta: Nullable<ShortUrlMeta>) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateOrUndefined = (shortUrl: ShortUrl | undefined, dateName: 'validSince' | 'validUntil') => {
|
const dateOrNull = (shortUrl: ShortUrl | undefined, dateName: 'validSince' | 'validUntil') => {
|
||||||
const date = shortUrl?.meta?.[dateName];
|
const date = shortUrl?.meta?.[dateName];
|
||||||
|
|
||||||
return date ? moment(date) : undefined;
|
return date ? moment(date) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditMetaModal = (
|
const EditMetaModal = (
|
||||||
{ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }: EditMetaModalConnectProps,
|
{ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }: EditMetaModalConnectProps,
|
||||||
) => {
|
) => {
|
||||||
const { saving, error } = shortUrlMeta;
|
const { saving, error, errorData } = shortUrlMeta;
|
||||||
const url = shortUrl && (shortUrl.shortUrl || '');
|
const url = shortUrl && (shortUrl.shortUrl || '');
|
||||||
const [ validSince, setValidSince ] = useState(dateOrUndefined(shortUrl, 'validSince'));
|
const [ validSince, setValidSince ] = useState(dateOrNull(shortUrl, 'validSince'));
|
||||||
const [ validUntil, setValidUntil ] = useState(dateOrUndefined(shortUrl, 'validUntil'));
|
const [ validUntil, setValidUntil ] = useState(dateOrNull(shortUrl, 'validUntil'));
|
||||||
const [ maxVisits, setMaxVisits ] = useState(shortUrl?.meta?.maxVisits);
|
const [ maxVisits, setMaxVisits ] = useState(shortUrl?.meta?.maxVisits);
|
||||||
|
|
||||||
const close = pipe(resetShortUrlMeta, toggle);
|
const close = pipe(resetShortUrlMeta, toggle);
|
||||||
@@ -56,7 +58,7 @@ const EditMetaModal = (
|
|||||||
selected={validSince}
|
selected={validSince}
|
||||||
maxDate={validUntil}
|
maxDate={validUntil}
|
||||||
isClearable
|
isClearable
|
||||||
onChange={setValidSince as any}
|
onChange={setValidSince}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
@@ -77,10 +79,14 @@ const EditMetaModal = (
|
|||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setMaxVisits(Number(e.target.value))}
|
onChange={(e: ChangeEvent<HTMLInputElement>) => setMaxVisits(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
<Result type="error" small className="mt-2">
|
||||||
Something went wrong while saving the metadata :(
|
<ShlinkApiError
|
||||||
</div>
|
errorData={errorData}
|
||||||
|
fallbackMessage="Something went wrong while saving the metadata :("
|
||||||
|
/>
|
||||||
|
</Result>
|
||||||
)}
|
)}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, Button } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, Button } from 'reactstrap';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { ShortUrlEdition } from '../reducers/shortUrlEdition';
|
import { ShortUrlEdition } from '../reducers/shortUrlEdition';
|
||||||
import { handleEventPreventingDefault, hasValue, OptionalString } from '../../utils/utils';
|
import { handleEventPreventingDefault, hasValue, OptionalString } from '../../utils/utils';
|
||||||
import { ShortUrlModalProps } from '../data';
|
import { ShortUrlModalProps } from '../data';
|
||||||
|
import { Result } from '../../utils/Result';
|
||||||
|
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||||
|
|
||||||
interface EditShortUrlModalProps extends ShortUrlModalProps {
|
interface EditShortUrlModalProps extends ShortUrlModalProps {
|
||||||
shortUrlEdition: ShortUrlEdition;
|
shortUrlEdition: ShortUrlEdition;
|
||||||
@@ -11,14 +13,14 @@ interface EditShortUrlModalProps extends ShortUrlModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShortUrl }: EditShortUrlModalProps) => {
|
const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShortUrl }: EditShortUrlModalProps) => {
|
||||||
const { saving, error } = shortUrlEdition;
|
const { saving, error, errorData } = shortUrlEdition;
|
||||||
const url = shortUrl?.shortUrl ?? '';
|
const url = shortUrl?.shortUrl ?? '';
|
||||||
const [ longUrl, setLongUrl ] = useState(shortUrl.longUrl);
|
const [ longUrl, setLongUrl ] = useState(shortUrl.longUrl);
|
||||||
|
|
||||||
const doEdit = async () => editShortUrl(shortUrl.shortCode, shortUrl.domain, longUrl).then(toggle);
|
const doEdit = async () => editShortUrl(shortUrl.shortCode, shortUrl.domain, longUrl).then(toggle);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
|
||||||
<ModalHeader toggle={toggle}>
|
<ModalHeader toggle={toggle}>
|
||||||
Edit long URL for <ExternalLink href={url} />
|
Edit long URL for <ExternalLink href={url} />
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
@@ -34,9 +36,12 @@ const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShor
|
|||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
<Result type="error" small className="mt-2">
|
||||||
Something went wrong while saving the long URL :(
|
<ShlinkApiError
|
||||||
</div>
|
errorData={errorData}
|
||||||
|
fallbackMessage="Something went wrong while saving the long URL :("
|
||||||
|
/>
|
||||||
|
</Result>
|
||||||
)}
|
)}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { ShortUrlTags } from '../reducers/shortUrlTags';
|
import { ShortUrlTags } from '../reducers/shortUrlTags';
|
||||||
import { ShortUrlModalProps } from '../data';
|
import { ShortUrlModalProps } from '../data';
|
||||||
import { OptionalString } from '../../utils/utils';
|
import { OptionalString } from '../../utils/utils';
|
||||||
import { TagsSelectorProps } from '../../tags/helpers/TagsSelector';
|
import { TagsSelectorProps } from '../../tags/helpers/TagsSelector';
|
||||||
|
import { Result } from '../../utils/Result';
|
||||||
|
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||||
|
|
||||||
interface EditTagsModalProps extends ShortUrlModalProps {
|
interface EditTagsModalProps extends ShortUrlModalProps {
|
||||||
shortUrlTags: ShortUrlTags;
|
shortUrlTags: ShortUrlTags;
|
||||||
@@ -19,6 +21,7 @@ const EditTagsModal = (TagsSelector: FC<TagsSelectorProps>) => (
|
|||||||
|
|
||||||
useEffect(() => resetShortUrlsTags, []);
|
useEffect(() => resetShortUrlsTags, []);
|
||||||
|
|
||||||
|
const { saving, error, errorData } = shortUrlTags;
|
||||||
const url = shortUrl?.shortUrl ?? '';
|
const url = shortUrl?.shortUrl ?? '';
|
||||||
const saveTags = async () => editShortUrlTags(shortUrl.shortCode, shortUrl.domain, selectedTags)
|
const saveTags = async () => editShortUrlTags(shortUrl.shortCode, shortUrl.domain, selectedTags)
|
||||||
.then(toggle)
|
.then(toggle)
|
||||||
@@ -31,16 +34,16 @@ const EditTagsModal = (TagsSelector: FC<TagsSelectorProps>) => (
|
|||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<TagsSelector tags={selectedTags} onChange={setSelectedTags} />
|
<TagsSelector tags={selectedTags} onChange={setSelectedTags} />
|
||||||
{shortUrlTags.error && (
|
{error && (
|
||||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
<Result type="error" small className="mt-2">
|
||||||
Something went wrong while saving the tags :(
|
<ShlinkApiError errorData={errorData} fallbackMessage="Something went wrong while saving the tags :(" />
|
||||||
</div>
|
</Result>
|
||||||
)}
|
)}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<button className="btn btn-link" onClick={toggle}>Cancel</button>
|
<button className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||||
<button className="btn btn-primary" type="button" disabled={shortUrlTags.saving} onClick={saveTags}>
|
<button className="btn btn-primary" type="button" disabled={saving} onClick={saveTags}>
|
||||||
{shortUrlTags.saving ? 'Saving tags...' : 'Save tags'}
|
{saving ? 'Saving tags...' : 'Save tags'}
|
||||||
</button>
|
</button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
@import '../../utils/mixins/horizontal-align';
|
|
||||||
|
|
||||||
.preview-modal__img {
|
|
||||||
max-width: 100%;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-modal__loader {
|
|
||||||
@include horizontal-align();
|
|
||||||
z-index: 1;
|
|
||||||
top: 1rem;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
|
||||||
import { ExternalLink } from 'react-external-link';
|
|
||||||
import { ShortUrlModalProps } from '../data';
|
|
||||||
import './PreviewModal.scss';
|
|
||||||
|
|
||||||
const PreviewModal = ({ shortUrl: { shortUrl }, toggle, isOpen }: ShortUrlModalProps) => (
|
|
||||||
<Modal isOpen={isOpen} toggle={toggle} size="lg">
|
|
||||||
<ModalHeader toggle={toggle}>
|
|
||||||
Preview for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
|
|
||||||
</ModalHeader>
|
|
||||||
<ModalBody>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="preview-modal__loader">Loading...</p>
|
|
||||||
<img src={`${shortUrl}/preview`} className="preview-modal__img" alt="Preview" />
|
|
||||||
</div>
|
|
||||||
</ModalBody>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default PreviewModal;
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { ShortUrlModalProps } from '../data';
|
import { ShortUrlModalProps } from '../data';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user