mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-26 11:46:39 +00:00
Compare commits
131 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 |
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 .
|
||||
2
.github/workflows/docker-image-build.yml
vendored
2
.github/workflows/docker-image-build.yml
vendored
@@ -9,7 +9,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
2
.github/workflows/publish-release.yml
vendored
2
.github/workflows/publish-release.yml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
tools:
|
||||
external_code_coverage:
|
||||
timeout: 1200
|
||||
43
.travis.yml
43
.travis.yml
@@ -1,43 +0,0 @@
|
||||
dist: bionic
|
||||
|
||||
language: node_js
|
||||
|
||||
branches:
|
||||
only:
|
||||
- /.*/
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- node_modules
|
||||
|
||||
node_js:
|
||||
- '14.15.0'
|
||||
|
||||
jobs:
|
||||
fast_finish: true
|
||||
allow_failures:
|
||||
- name: 'Lint'
|
||||
- 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 .
|
||||
54
CHANGELOG.md
54
CHANGELOG.md
@@ -4,6 +4,60 @@ 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).
|
||||
|
||||
## [3.0.1] - 2020-12-30
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### 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*
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
FROM node:14.15.0-alpine as node
|
||||
FROM node:14.15-alpine as node
|
||||
COPY . /shlink-web-client
|
||||
ARG VERSION="latest"
|
||||
ENV VERSION ${VERSION}
|
||||
RUN cd /shlink-web-client && \
|
||||
npm install && npm run build -- ${VERSION} --no-dist
|
||||
|
||||
FROM nginx:1.19.3-alpine
|
||||
FROM nginx:1.19.6-alpine
|
||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
|
||||
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
# shlink-web-client
|
||||
|
||||
[](https://travis-ci.com/shlinkio/shlink-web-client)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/)
|
||||
[](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
|
||||
[](https://app.codecov.io/gh/shlinkio/shlink-web-client)
|
||||
[](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://slnk.to/donate)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ version: '3'
|
||||
services:
|
||||
shlink_web_client_node:
|
||||
container_name: shlink_web_client_node
|
||||
image: node:14.15.0-alpine
|
||||
image: node:14.15-alpine
|
||||
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
|
||||
407
package-lock.json
generated
407
package-lock.json
generated
@@ -3810,12 +3810,12 @@
|
||||
}
|
||||
},
|
||||
"@stryker-mutator/api": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-4.1.2.tgz",
|
||||
"integrity": "sha512-Oq4RO+FEhcaJciCwM2HuYJaKfnyijmQEGkxCrNK2y5NVTF7lIfTZblLuxEh6+QmTnxuagRxpA+kfgj8j4tmpcw==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-4.3.1.tgz",
|
||||
"integrity": "sha512-2eXVXh7yhRML4a6tymx5WU8KmA1BNemIqx7aWnh8Mqx58WSTBfbyipm4JsS7/mzrDLQAguQmqTqsdNJlMkr1eQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"mutation-testing-report-schema": "~1.4.3",
|
||||
"mutation-testing-report-schema": "~1.5.2",
|
||||
"surrial": "~2.0.2",
|
||||
"tslib": "~2.0.0"
|
||||
},
|
||||
@@ -3829,18 +3829,18 @@
|
||||
}
|
||||
},
|
||||
"@stryker-mutator/core": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/core/-/core-4.1.2.tgz",
|
||||
"integrity": "sha512-3PgOHHy4IQNP4Gd1yBrLXJQ7CY73Xepzb+LAg4Wdp8pMcjouzTKE+PL+Wjq5AkhF6DjgwF5deCk94GpbikKoPQ==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/core/-/core-4.3.1.tgz",
|
||||
"integrity": "sha512-QjLaO7tvIOlKou6D1iOys46fmbJM42Ps2Wn5hP7G2cBZHB3DgjfYtCSgwF/fgoFybHPukI/8O/2gm302N/jgow==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@stryker-mutator/api": "4.1.2",
|
||||
"@stryker-mutator/instrumenter": "4.1.2",
|
||||
"@stryker-mutator/util": "4.1.2",
|
||||
"@stryker-mutator/api": "4.3.1",
|
||||
"@stryker-mutator/instrumenter": "4.3.1",
|
||||
"@stryker-mutator/util": "4.3.1",
|
||||
"ajv": "~6.12.0",
|
||||
"chalk": "~4.1.0",
|
||||
"commander": "~6.2.0",
|
||||
"execa": "~4.0.2",
|
||||
"execa": "~5.0.0",
|
||||
"file-url": "~3.0.0",
|
||||
"get-port": "~5.0.0",
|
||||
"glob": "~7.1.2",
|
||||
@@ -3850,8 +3850,8 @@
|
||||
"log4js": "~6.2.1",
|
||||
"minimatch": "~3.0.4",
|
||||
"mkdirp": "~1.0.3",
|
||||
"mutation-testing-elements": "~1.4.3",
|
||||
"mutation-testing-metrics": "~1.4.3",
|
||||
"mutation-testing-elements": "~1.5.2",
|
||||
"mutation-testing-metrics": "~1.5.2",
|
||||
"npm-run-path": "~4.0.1",
|
||||
"progress": "~2.0.0",
|
||||
"rimraf": "~3.0.0",
|
||||
@@ -3861,70 +3861,9 @@
|
||||
"tree-kill": "~1.2.2",
|
||||
"tslib": "~2.0.0",
|
||||
"typed-inject": "~3.0.0",
|
||||
"typed-rest-client": "~1.7.1"
|
||||
"typed-rest-client": "~1.8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
}
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
|
||||
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
|
||||
"dev": true
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-convert": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"chalk": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
|
||||
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"cli-cursor": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
|
||||
"integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"restore-cursor": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"cli-width": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz",
|
||||
"integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==",
|
||||
"dev": true
|
||||
},
|
||||
"color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-name": "~1.1.4"
|
||||
}
|
||||
},
|
||||
"cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
@@ -3937,77 +3876,32 @@
|
||||
}
|
||||
},
|
||||
"execa": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-4.0.3.tgz",
|
||||
"integrity": "sha512-WFDXGHckXPWZX19t1kCsXzOpqX9LWYNqn4C+HqZlk/V0imTkzJZqf87ZBhvpHaftERYknpk0fjSylnXVlVgI0A==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.0.0.tgz",
|
||||
"integrity": "sha512-ov6w/2LCiuyO4RLYGdpFGjkcs0wMTgGE8PrkTHikeUy5iJekXyPIKUjifk5CsE0pt7sMCrMZ3YNqoCj6idQOnQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cross-spawn": "^7.0.0",
|
||||
"get-stream": "^5.0.0",
|
||||
"human-signals": "^1.1.1",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"get-stream": "^6.0.0",
|
||||
"human-signals": "^2.1.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"merge-stream": "^2.0.0",
|
||||
"npm-run-path": "^4.0.0",
|
||||
"onetime": "^5.1.0",
|
||||
"signal-exit": "^3.0.2",
|
||||
"npm-run-path": "^4.0.1",
|
||||
"onetime": "^5.1.2",
|
||||
"signal-exit": "^3.0.3",
|
||||
"strip-final-newline": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
},
|
||||
"figures": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
|
||||
"integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"escape-string-regexp": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"get-stream": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
||||
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"pump": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.0.tgz",
|
||||
"integrity": "sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg==",
|
||||
"dev": true
|
||||
},
|
||||
"inquirer": {
|
||||
"version": "7.3.3",
|
||||
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz",
|
||||
"integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-escapes": "^4.2.1",
|
||||
"chalk": "^4.1.0",
|
||||
"cli-cursor": "^3.1.0",
|
||||
"cli-width": "^3.0.0",
|
||||
"external-editor": "^3.0.3",
|
||||
"figures": "^3.0.0",
|
||||
"lodash": "^4.17.19",
|
||||
"mute-stream": "0.0.8",
|
||||
"run-async": "^2.4.0",
|
||||
"rxjs": "^6.6.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"through": "^2.3.6"
|
||||
}
|
||||
},
|
||||
"is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"human-signals": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
|
||||
"dev": true
|
||||
},
|
||||
"is-stream": {
|
||||
@@ -4016,12 +3910,6 @@
|
||||
"integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==",
|
||||
"dev": true
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.20",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
|
||||
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==",
|
||||
"dev": true
|
||||
},
|
||||
"mimic-fn": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||
@@ -4034,12 +3922,6 @@
|
||||
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
|
||||
"dev": true
|
||||
},
|
||||
"mute-stream": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
|
||||
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
|
||||
"dev": true
|
||||
},
|
||||
"npm-run-path": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
||||
@@ -4064,16 +3946,6 @@
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true
|
||||
},
|
||||
"restore-cursor": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
|
||||
"integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"onetime": "^5.1.0",
|
||||
"signal-exit": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
@@ -4083,12 +3955,6 @@
|
||||
"glob": "^7.1.3"
|
||||
}
|
||||
},
|
||||
"run-async": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
|
||||
"integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==",
|
||||
"dev": true
|
||||
},
|
||||
"shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -4104,41 +3970,18 @@
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true
|
||||
},
|
||||
"signal-exit": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz",
|
||||
"integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==",
|
||||
"dev": true
|
||||
},
|
||||
"source-map": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
|
||||
"integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==",
|
||||
"dev": true
|
||||
},
|
||||
"string-width": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz",
|
||||
"integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"strip-ansi": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
|
||||
"integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"has-flag": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"tslib": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.3.tgz",
|
||||
@@ -4157,9 +4000,9 @@
|
||||
}
|
||||
},
|
||||
"@stryker-mutator/instrumenter": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/instrumenter/-/instrumenter-4.1.2.tgz",
|
||||
"integrity": "sha512-uPmEPiQ2iMOy/ekxMBOSTqcu3bCjOaD0cCi/VaUqgkuCHUftfFUjk0TP+6whL26QKGfi4Zm9WzNKvswOgmB9yw==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/instrumenter/-/instrumenter-4.3.1.tgz",
|
||||
"integrity": "sha512-NaYopLzD7VrwsryeNDrDEZ3YLXNZXmzQn6A4PukX7t1ETc+4YW0cj0q57IN0UydnYLCEhj6MHmxZAuUZmvTL4Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/core": "~7.12.3",
|
||||
@@ -4169,62 +4012,21 @@
|
||||
"@babel/plugin-proposal-decorators": "~7.12.1 ",
|
||||
"@babel/plugin-proposal-private-methods": "^7.12.1",
|
||||
"@babel/preset-typescript": "~7.12.1 ",
|
||||
"@stryker-mutator/api": "4.1.2",
|
||||
"@stryker-mutator/util": "4.1.2",
|
||||
"@stryker-mutator/api": "4.3.1",
|
||||
"@stryker-mutator/util": "4.3.1",
|
||||
"angular-html-parser": "~1.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/generator": {
|
||||
"version": "7.12.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.12.5.tgz",
|
||||
"integrity": "sha512-m16TQQJ8hPt7E+OS/XVQg/7U184MLXtvuGbCdA7na61vha+ImkyyNM/9DDA0unYCVZn3ZOhng+qz48/KBOT96A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.12.5",
|
||||
"jsesc": "^2.5.1",
|
||||
"source-map": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"@babel/parser": {
|
||||
"version": "7.12.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.12.5.tgz",
|
||||
"integrity": "sha512-FVM6RZQ0mn2KCf1VUED7KepYeUWoVShczewOCfm3nzoBybaih51h+sYVVGthW9M6lPByEPTQf+xm27PBdlpwmQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/types": {
|
||||
"version": "7.12.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.6.tgz",
|
||||
"integrity": "sha512-hwyjw6GvjBLiyy3W0YQf0Z5Zf4NpYejUnKFcfcUhZCSffoBBp30w6wP2Wn6pk31jMYZvcOrB/1b7cGXvEoKogA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.10.4",
|
||||
"lodash": "^4.17.19",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.20",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
|
||||
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==",
|
||||
"dev": true
|
||||
},
|
||||
"source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
"integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@stryker-mutator/jest-runner": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/jest-runner/-/jest-runner-4.1.2.tgz",
|
||||
"integrity": "sha512-vxj3hUGjAqtCbZTsh+I99Q4xjmD/hXxLU0N7Ey7LXucqfvJErANbVvv+6md0YgpVbQnR9BTl59A09gZFTiEe3g==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/jest-runner/-/jest-runner-4.3.1.tgz",
|
||||
"integrity": "sha512-19jj9mUR92DDBHveT1xOL5i8H2TynJZ5zNR0oFuoco2p8woXNH8CfjMh42NomawD60r4KqupiKAlMAW2f7IsjQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@stryker-mutator/api": "4.1.2",
|
||||
"@stryker-mutator/util": "4.1.2",
|
||||
"semver": "~6.3.0"
|
||||
"@stryker-mutator/api": "4.3.1",
|
||||
"@stryker-mutator/util": "4.3.1",
|
||||
"semver": "~6.3.0",
|
||||
"tslib": "~2.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"semver": {
|
||||
@@ -4232,32 +4034,56 @@
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
|
||||
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
|
||||
"dev": true
|
||||
},
|
||||
"tslib": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.3.tgz",
|
||||
"integrity": "sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@stryker-mutator/typescript-checker": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/typescript-checker/-/typescript-checker-4.1.2.tgz",
|
||||
"integrity": "sha512-id9mS08OWj89h2lLqXJPCHAPmjoshnrqO8SO3P9k+b+NZ6J5XZf8mLT/xxWOQd1cct6zFBAItEerHpmMeQKL6Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/typescript-checker/-/typescript-checker-4.3.1.tgz",
|
||||
"integrity": "sha512-M0VAd9/TNjXwCrL3n4ofihZJN7w1Kd5eEFm4DDs6xrl21gfFspE2sPsLuD7hxToEXN5mJg8jMYXVCZWwakDmXw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@stryker-mutator/api": "4.1.2",
|
||||
"@stryker-mutator/util": "4.1.2",
|
||||
"@stryker-mutator/api": "4.3.1",
|
||||
"@stryker-mutator/util": "4.3.1",
|
||||
"semver": "~7.3.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"yallist": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"semver": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz",
|
||||
"integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==",
|
||||
"version": "7.3.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz",
|
||||
"integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"lru-cache": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@stryker-mutator/util": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/util/-/util-4.1.2.tgz",
|
||||
"integrity": "sha512-b0RuhIMPghNoZqP81aAYsqa0cL/eEkaxEdrrUyZwECIss1Ofg5+KU0SNdjw/VJWNvGsh0tE6rEyjFW+gZJ/TSg==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@stryker-mutator/util/-/util-4.3.1.tgz",
|
||||
"integrity": "sha512-H5XHukO22BLnn0uIl39AUIabTqNTC7rVtrz8Hb1CeGxYF5d5fn5dUGqjUprW9jsxRzt8N3o/j2ysW1ikfB24iA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"lodash.flatmap": "~4.5.0"
|
||||
@@ -9199,9 +9025,9 @@
|
||||
}
|
||||
},
|
||||
"commander": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
|
||||
"integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
|
||||
"integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
|
||||
"dev": true
|
||||
},
|
||||
"commondir": {
|
||||
@@ -16554,9 +16380,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz",
|
||||
"integrity": "sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
|
||||
"integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ms": "2.1.2"
|
||||
@@ -17241,24 +17067,24 @@
|
||||
"dev": true
|
||||
},
|
||||
"mutation-testing-elements": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/mutation-testing-elements/-/mutation-testing-elements-1.4.3.tgz",
|
||||
"integrity": "sha512-TxggiswcnNDYh7MI3yZtoAUZocbLDaz95SKHm2YjddUcgglwvc0xv/1GDQ/6UwweRhAISxXi7HpEUFBkSUJQhg==",
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/mutation-testing-elements/-/mutation-testing-elements-1.5.2.tgz",
|
||||
"integrity": "sha512-ugngX+MB6tnwFxirDVSFiCQdbGMLCUQ7oPqltk5QJ/pye8aCyuA90C3Gw8klHk4aRL1JR91FEupacR9CgGXC7w==",
|
||||
"dev": true
|
||||
},
|
||||
"mutation-testing-metrics": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/mutation-testing-metrics/-/mutation-testing-metrics-1.4.3.tgz",
|
||||
"integrity": "sha512-b+5upZuuEDyY5oWb/3Jvo8kMxo9VHVP8t7ryZproBbp9bclDbS3c6kFScR6qJPFQ90NK2S7ab/cGxRHMXnNbJQ==",
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/mutation-testing-metrics/-/mutation-testing-metrics-1.5.2.tgz",
|
||||
"integrity": "sha512-KRMBf1tRNh1snwt+5rZu4Le+dgam+GSX+39WfzJG9k55f/+isRn4hv3dhC4Vl/XdlJ29/Z0dTSe7ZFsWBTABUA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"mutation-testing-report-schema": "^1.4.3"
|
||||
"mutation-testing-report-schema": "^1.5.2"
|
||||
}
|
||||
},
|
||||
"mutation-testing-report-schema": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/mutation-testing-report-schema/-/mutation-testing-report-schema-1.4.3.tgz",
|
||||
"integrity": "sha512-+TLMYFGFL8MtcbIF0g9VG+OS5qkPPaZtvyT0v/XWrKgrbAhpnJYNsZV9/xMWuO/V8WC02mLksn/HrDnKnIVqXw==",
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/mutation-testing-report-schema/-/mutation-testing-report-schema-1.5.2.tgz",
|
||||
"integrity": "sha512-ad90c42vMHa0S4ZZ3e5oZOzGAWg4G8JWto9MrmDkrwInf/Dq+Q8FupCOOTqed0V9FTWqv4sl5arRlYEbedW6Ww==",
|
||||
"dev": true
|
||||
},
|
||||
"mute-stream": {
|
||||
@@ -18002,23 +17828,6 @@
|
||||
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
|
||||
"dev": true
|
||||
},
|
||||
"ocular.js": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ocular.js/-/ocular.js-0.1.0.tgz",
|
||||
"integrity": "sha1-OhRqtZhk6X/7Evg+HY8Duav5qGY=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"commander": "^2.9.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz",
|
||||
"integrity": "sha1-9hmKqE5bg8RgVLlN3tv+1e6f8So=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"on-finished": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
||||
@@ -23092,9 +22901,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"react-external-link": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-external-link/-/react-external-link-1.1.1.tgz",
|
||||
"integrity": "sha512-e2WnTWkg81cuqxmDfjOalliAE20+Y/uD+lserN4uuwkwu+ciGLB3BMz4m7GnXh2+TowIi4sLtCL7zr7aDnIaqA=="
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-external-link/-/react-external-link-1.2.0.tgz",
|
||||
"integrity": "sha512-6Lm0pD6rxrvZGVrWIWda809cAtrIlM3fDsKh9y5bKEVpOI0FTO2nTnxaqEood8+rw3svHJgpJGN6lZHO69ZTAQ=="
|
||||
},
|
||||
"react-is": {
|
||||
"version": "16.7.0",
|
||||
@@ -25091,9 +24900,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"debug": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz",
|
||||
"integrity": "sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
|
||||
"integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ms": "2.1.2"
|
||||
@@ -27324,9 +27133,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"typed-rest-client": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.7.3.tgz",
|
||||
"integrity": "sha512-CwTpx/TkRHGZoHkJhBcp4X8K3/WtlzSHVQR0OIFnt10j4tgy4ypgq/SrrgVpA1s6tAL49Q6J3R5C0Cgfh2ddqA==",
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.0.tgz",
|
||||
"integrity": "sha512-Nu1MrdH6ECrRW5gHoRAdubgCs4oH6q5/J76jsEC8bVDfvVoVPkigukPalhMHPwb7ZvpsZqPptd5zpt/QdtrdBw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"qs": "^6.9.1",
|
||||
|
||||
14
package.json
14
package.json
@@ -6,7 +6,7 @@
|
||||
"repository": "https://github.com/shlinkio/shlink-web-client",
|
||||
"license": "MIT",
|
||||
"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:fix": "npm run lint:js -- --fix",
|
||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||
@@ -17,8 +17,7 @@
|
||||
"test": "node scripts/test.js --env=jsdom --colors",
|
||||
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
||||
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
|
||||
"mutate": "./node_modules/.bin/stryker run --concurrency 4",
|
||||
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES"
|
||||
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.15.1",
|
||||
@@ -47,7 +46,7 @@
|
||||
"react-copy-to-clipboard": "^5.0.2",
|
||||
"react-datepicker": "^3.3.0",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-external-link": "^1.1.1",
|
||||
"react-external-link": "^1.2.0",
|
||||
"react-leaflet": "^3.0.2",
|
||||
"react-moment": "^1.0.0",
|
||||
"react-redux": "^7.2.2",
|
||||
@@ -65,9 +64,9 @@
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.12.1",
|
||||
"@shlinkio/eslint-config-js-coding-standard": "~1.1.0",
|
||||
"@stryker-mutator/core": "^4.1.2",
|
||||
"@stryker-mutator/jest-runner": "^4.1.2",
|
||||
"@stryker-mutator/typescript-checker": "^4.1.2",
|
||||
"@stryker-mutator/core": "^4.3.1",
|
||||
"@stryker-mutator/jest-runner": "^4.3.1",
|
||||
"@stryker-mutator/typescript-checker": "^4.3.1",
|
||||
"@svgr/webpack": "^5.4.0",
|
||||
"@types/chart.js": "^2.9.27",
|
||||
"@types/classnames": "^2.2.11",
|
||||
@@ -118,7 +117,6 @@
|
||||
"jest-resolve": "^26.6.2",
|
||||
"mini-css-extract-plugin": "^1.3.1",
|
||||
"object-assign": "^4.1.1",
|
||||
"ocular.js": "^0.1.0",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.4",
|
||||
"pnp-webpack-plugin": "^1.6.4",
|
||||
"postcss": "^8.1.7",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 2.3 MiB |
@@ -16,7 +16,7 @@ const App = (
|
||||
CreateServer: FC,
|
||||
EditServer: FC,
|
||||
Settings: FC,
|
||||
ShlinkVersions: FC,
|
||||
ShlinkVersionsContainer: FC,
|
||||
) => ({ fetchServers, servers }: AppProps) => {
|
||||
// On first load, try to fetch the remote servers if the list is empty
|
||||
useEffect(() => {
|
||||
@@ -41,8 +41,8 @@ const App = (
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<div className="shlink-footer text-center text-md-right">
|
||||
<ShlinkVersions />
|
||||
<div className="shlink-footer">
|
||||
<ShlinkVersionsContainer />
|
||||
</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 { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||
import { ShortUrl, ShortUrlData } from '../../short-urls/data';
|
||||
import { OptionalString } from '../utils';
|
||||
import { OptionalString } from '../../utils/utils';
|
||||
import {
|
||||
ShlinkHealth,
|
||||
ShlinkMercureInfo,
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
ShlinkVisits,
|
||||
ShlinkVisitsParams,
|
||||
ShlinkShortUrlMeta,
|
||||
} from './types';
|
||||
ShlinkDomain,
|
||||
ShlinkDomainsResponse,
|
||||
ShlinkVisitsOverview,
|
||||
} from '../types';
|
||||
|
||||
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
||||
const rejectNilProps = reject(isNil);
|
||||
@@ -48,6 +51,10 @@ export default class ShlinkApiClient {
|
||||
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
|
||||
.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> =>
|
||||
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain })
|
||||
.then(({ data }) => data);
|
||||
@@ -93,6 +100,9 @@ export default class ShlinkApiClient {
|
||||
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
|
||||
.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>> => {
|
||||
try {
|
||||
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
|
||||
// caller handle it
|
||||
if (!apiVersionIsNotSupported || this.apiVersion === 1) {
|
||||
if (!apiVersionIsNotSupported || this.apiVersion === 2) {
|
||||
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 { ShortUrl, ShortUrlMeta } from '../../short-urls/data'; // FIXME Should be defined as part of this module
|
||||
import { OptionalString } from '../utils';
|
||||
import { Visit } from '../../visits/types';
|
||||
import { OptionalString } from '../../utils/utils';
|
||||
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
|
||||
|
||||
export interface ShlinkShortUrlsResponse {
|
||||
data: ShortUrl[];
|
||||
@@ -25,22 +25,27 @@ interface ShlinkTagsStats {
|
||||
|
||||
export interface ShlinkTags {
|
||||
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 {
|
||||
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 {
|
||||
currentPage: number;
|
||||
pagesCount: number;
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
export interface ShlinkVisits {
|
||||
data: Visit[];
|
||||
pagination?: ShlinkPaginator; // Is only optional in old Shlink versions
|
||||
pagination: ShlinkPaginator;
|
||||
}
|
||||
|
||||
export interface ShlinkVisitsOverview {
|
||||
visitsCount: number;
|
||||
}
|
||||
|
||||
export interface ShlinkVisitsParams {
|
||||
@@ -55,12 +60,30 @@ export interface ShlinkShortUrlMeta extends ShortUrlMeta {
|
||||
longUrl?: string;
|
||||
}
|
||||
|
||||
export interface ShlinkDomain {
|
||||
domain: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface ShlinkDomainsResponse {
|
||||
data: ShlinkDomain[];
|
||||
}
|
||||
|
||||
export interface ProblemDetailsError {
|
||||
type: string;
|
||||
detail: string;
|
||||
title: string;
|
||||
status: number;
|
||||
error?: string; // Deprecated
|
||||
message?: string; // Deprecated
|
||||
|
||||
[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/mixins/vertical-align';
|
||||
|
||||
$asideMenuMobileWidth: 280px;
|
||||
|
||||
.aside-menu {
|
||||
background-color: #f7f7f7;
|
||||
width: $asideMenuWidth;
|
||||
background-color: white;
|
||||
box-shadow: rgba(0, 0, 0, .05) 0 8px 15px;
|
||||
position: fixed !important;
|
||||
padding-top: 13px;
|
||||
padding-bottom: 10px;
|
||||
@@ -22,7 +22,6 @@ $asideMenuMobileWidth: 280px;
|
||||
}
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
width: $asideMenuMobileWidth !important;
|
||||
transition: left 300ms;
|
||||
top: $headerHeight - 3px;
|
||||
box-shadow: -10px 0 50px 11px rgba(0, 0, 0, .55);
|
||||
@@ -31,7 +30,7 @@ $asideMenuMobileWidth: 280px;
|
||||
|
||||
.aside-menu--hidden {
|
||||
@media (max-width: $smMax) {
|
||||
left: -($asideMenuMobileWidth + 35px);
|
||||
left: -($asideMenuWidth + 35px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,10 +43,14 @@ $asideMenuMobileWidth: 280px;
|
||||
margin: 0 -15px;
|
||||
text-decoration: none !important;
|
||||
cursor: pointer;
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.aside-menu__item:hover {
|
||||
background-color: $lightHoverColor;
|
||||
background-color: $lightColor;
|
||||
}
|
||||
|
||||
.aside-menu__item--selected {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
faLink as createIcon,
|
||||
faTags as tagsIcon,
|
||||
faPen as editIcon,
|
||||
faHome as overviewIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { FC } from 'react';
|
||||
@@ -21,7 +22,6 @@ export interface AsideMenuProps {
|
||||
|
||||
interface AsideMenuItemProps extends NavLinkProps {
|
||||
to: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
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>) => (
|
||||
{ selectedServer, className, showOnMobile = false }: AsideMenuProps,
|
||||
{ selectedServer, showOnMobile = false }: AsideMenuProps,
|
||||
) => {
|
||||
const serverId = selectedServer ? selectedServer.id : '';
|
||||
const asideClass = classNames('aside-menu', className, {
|
||||
const asideClass = classNames('aside-menu', {
|
||||
'aside-menu--hidden': !showOnMobile,
|
||||
});
|
||||
const shortUrlsIsActive = (_: null, location: Location) => location.pathname.match('/list-short-urls') !== null;
|
||||
@@ -48,6 +48,10 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||
return (
|
||||
<aside className={asideClass}>
|
||||
<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}>
|
||||
<FontAwesomeIcon icon={listIcon} />
|
||||
<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 { Component, ReactNode } from 'react';
|
||||
import { Button } from 'reactstrap';
|
||||
import './ErrorHandler.scss';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
|
||||
interface ErrorHandlerState {
|
||||
hasError: boolean;
|
||||
@@ -25,14 +25,16 @@ const ErrorHandler = (
|
||||
}
|
||||
}
|
||||
|
||||
public render(): ReactNode | undefined {
|
||||
public render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-handler">
|
||||
<h1>Oops! This is awkward :S</h1>
|
||||
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
||||
<br />
|
||||
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
|
||||
<div className="home">
|
||||
<SimpleCard className="p-4">
|
||||
<h1>Oops! This is awkward :S</h1>
|
||||
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
||||
<br />
|
||||
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
|
||||
</SimpleCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
@import '../utils/base';
|
||||
@import '../utils/mixins/vertical-align';
|
||||
|
||||
.home {
|
||||
text-align: center;
|
||||
height: calc(100vh - #{$headerHeight} - #{($footer-height + $footer-margin)});
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
position: relative;
|
||||
padding-top: 15px;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding-top: 0;
|
||||
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 {
|
||||
text-align: center;
|
||||
font-size: 1.75rem;
|
||||
margin: 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.home__servers-container {
|
||||
@media (min-width: $mdMin) {
|
||||
border-left: 1px solid rgba(0, 0, 0, .125);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { isEmpty, values } from 'ramda';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, Row } from 'reactstrap';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import ServersListGroup from '../servers/ServersListGroup';
|
||||
import './Home.scss';
|
||||
import { ServersMap } from '../servers/data';
|
||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||
|
||||
export interface HomeProps {
|
||||
servers: ServersMap;
|
||||
@@ -14,11 +17,32 @@ const Home = ({ servers }: HomeProps) => {
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<h1 className="home__title">Welcome to Shlink</h1>
|
||||
<ServersListGroup servers={serversList}>
|
||||
{hasServers && <span>Please, select a server.</span>}
|
||||
{!hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
|
||||
</ServersListGroup>
|
||||
<Card className="home__main-card">
|
||||
<Row noGutters>
|
||||
<div className="col-md-5 d-none d-md-block">
|
||||
<div className="p-4">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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 { FC, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -6,7 +6,7 @@ import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } f
|
||||
import classNames from 'classnames';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import shlinkLogo from './shlink-logo-white.png';
|
||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||
import './MainHeader.scss';
|
||||
|
||||
const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps) => {
|
||||
@@ -15,14 +15,13 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
|
||||
|
||||
useEffect(close, [ location ]);
|
||||
|
||||
const createServerPath = '/server/create';
|
||||
const settingsPath = '/settings';
|
||||
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
||||
|
||||
return (
|
||||
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
||||
<NavbarBrand tag={Link} to="/">
|
||||
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
|
||||
<ShlinkLogo className="main-header__brand-logo" color="white" /> Shlink
|
||||
</NavbarBrand>
|
||||
|
||||
<NavbarToggler onClick={toggleOpen}>
|
||||
@@ -32,15 +31,10 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
|
||||
<Collapse navbar isOpen={isOpen}>
|
||||
<Nav navbar className="ml-auto">
|
||||
<NavItem>
|
||||
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
||||
<NavLink tag={Link} to={'/settings'} active={pathname === settingsPath}>
|
||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink tag={Link} to={createServerPath} active={pathname === createServerPath}>
|
||||
<FontAwesomeIcon icon={plusIcon} /> Add server
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<ServersDropdown />
|
||||
</Nav>
|
||||
</Collapse>
|
||||
|
||||
@@ -33,11 +33,11 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
.menu-layout__container {
|
||||
.menu-layout__container.menu-layout__container {
|
||||
padding: 20px 0 0;
|
||||
min-height: 100%;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 30px 15px 0;
|
||||
padding: 30px 0 0 $asideMenuWidth;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
@@ -20,6 +20,7 @@ const MenuLayout = (
|
||||
ShortUrlVisits: FC,
|
||||
TagVisits: FC,
|
||||
ServerError: FC,
|
||||
Overview: FC,
|
||||
) => withSelectedServer(({ location, selectedServer }) => {
|
||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
||||
|
||||
@@ -55,15 +56,17 @@ const MenuLayout = (
|
||||
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
||||
|
||||
<div {...swipeableProps} className="menu-layout__swipeable">
|
||||
<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">
|
||||
<div className="menu-layout__swipeable-inner">
|
||||
<AsideMenu selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
||||
<div className="menu-layout__container" onClick={() => hideSidebar()}>
|
||||
<div className="container-xl">
|
||||
<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/create-short-url" component={CreateShortUrl} />
|
||||
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||
{addTagsVisitsRoute && <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
||||
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||
{addTagsVisitsRoute && <Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||
<Route
|
||||
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.no-menu-wrapper {
|
||||
padding: 40px 20px 20px;
|
||||
padding: 15px 0 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 30px 20px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FC } from 'react';
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
|
||||
interface NotFoundProps {
|
||||
to?: string;
|
||||
@@ -7,13 +8,15 @@ interface NotFoundProps {
|
||||
|
||||
const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
||||
<div className="home">
|
||||
<h2>Oops! We could not find requested route.</h2>
|
||||
<p>
|
||||
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
||||
button.
|
||||
</p>
|
||||
<br />
|
||||
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
|
||||
<SimpleCard className="p-4">
|
||||
<h2>Oops! We could not find requested route.</h2>
|
||||
<p>
|
||||
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
||||
button.
|
||||
</p>
|
||||
<br />
|
||||
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
|
||||
</SimpleCard>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import classNames from 'classnames';
|
||||
import { pipe } from 'ramda';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||
import { isReachableServer } from '../servers/data';
|
||||
import { ShlinkVersionsContainerProps } from './ShlinkVersionsContainer';
|
||||
|
||||
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
||||
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
|
||||
|
||||
export interface ShlinkVersionsProps {
|
||||
selectedServer: SelectedServer;
|
||||
export interface ShlinkVersionsProps extends ShlinkVersionsContainerProps {
|
||||
clientVersion?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
|
||||
@@ -19,13 +17,11 @@ const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-cli
|
||||
</ExternalLink>
|
||||
);
|
||||
|
||||
const ShlinkVersions = (
|
||||
{ selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps,
|
||||
) => {
|
||||
const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps) => {
|
||||
const normalizedClientVersion = normalizeVersion(clientVersion);
|
||||
|
||||
return (
|
||||
<small className={classNames('text-muted', className)}>
|
||||
<small className="text-muted">
|
||||
{isReachableServer(selectedServer) &&
|
||||
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
overflow: hidden;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,9 @@
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: none;
|
||||
padding: 3px 5px;
|
||||
padding: 1px 0;
|
||||
width: 100%;
|
||||
margin-bottom: 6px;
|
||||
font-size: 1.25rem;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import Bottle, { Decorator } from 'bottlejs';
|
||||
import ScrollToTop from '../ScrollToTop';
|
||||
import MainHeader from '../MainHeader';
|
||||
@@ -5,13 +6,14 @@ import Home from '../Home';
|
||||
import MenuLayout from '../MenuLayout';
|
||||
import AsideMenu from '../AsideMenu';
|
||||
import ErrorHandler from '../ErrorHandler';
|
||||
import ShlinkVersions from '../ShlinkVersions';
|
||||
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||
bottle.constant('window', (global as any).window);
|
||||
bottle.constant('console', global.console);
|
||||
bottle.constant('axios', axios);
|
||||
|
||||
bottle.serviceFactory('ScrollToTop', ScrollToTop);
|
||||
bottle.decorator('ScrollToTop', withRouter);
|
||||
@@ -33,14 +35,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||
'ShortUrlVisits',
|
||||
'TagVisits',
|
||||
'ServerError',
|
||||
'Overview',
|
||||
);
|
||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
||||
bottle.decorator('MenuLayout', withRouter);
|
||||
|
||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||
|
||||
bottle.serviceFactory('ShlinkVersions', () => ShlinkVersions);
|
||||
bottle.decorator('ShlinkVersions', connect([ 'selectedServer' ]));
|
||||
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
|
||||
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer' ]));
|
||||
|
||||
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 { pick } from 'ramda';
|
||||
import App from '../App';
|
||||
import provideApiServices from '../api/services/provideServices';
|
||||
import provideCommonServices from '../common/services/provideServices';
|
||||
import provideShortUrlsServices from '../short-urls/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 provideMercureServices from '../mercure/services/provideServices';
|
||||
import provideSettingsServices from '../settings/services/provideServices';
|
||||
import provideDomainsServices from '../domains/services/provideServices';
|
||||
import { ConnectDecorator } from './types';
|
||||
|
||||
type LazyActionMap = Record<string, Function>;
|
||||
@@ -30,10 +32,21 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
|
||||
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' ]));
|
||||
|
||||
provideCommonServices(bottle, connect, withRouter);
|
||||
provideApiServices(bottle);
|
||||
provideShortUrlsServices(bottle, connect);
|
||||
provideServersServices(bottle, connect, withRouter);
|
||||
provideTagsServices(bottle, connect);
|
||||
@@ -41,5 +54,6 @@ provideVisitsServices(bottle, connect);
|
||||
provideUtilsServices(bottle);
|
||||
provideMercureServices(bottle);
|
||||
provideSettingsServices(bottle, connect);
|
||||
provideDomainsServices(bottle, connect);
|
||||
|
||||
export default container;
|
||||
|
||||
@@ -14,6 +14,8 @@ import { TagsList } from '../tags/reducers/tagsList';
|
||||
import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail';
|
||||
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
||||
import { TagVisits } from '../visits/reducers/tagVisits';
|
||||
import { DomainsList } from '../domains/reducers/domainsList';
|
||||
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||
|
||||
export interface ShlinkState {
|
||||
servers: ServersMap;
|
||||
@@ -33,6 +35,8 @@ export interface ShlinkState {
|
||||
tagEdit: TagEdition;
|
||||
mercureInfo: MercureInfo;
|
||||
settings: Settings;
|
||||
domainsList: DomainsList;
|
||||
visitsOverview: VisitsOverview;
|
||||
}
|
||||
|
||||
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 'node_modules/bootstrap/scss/bootstrap.scss';
|
||||
@import './common/react-tagsinput.scss';
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
background: $lightColor;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -14,6 +17,29 @@ body,
|
||||
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) {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -29,6 +55,10 @@ body,
|
||||
background-color: $mainColor;
|
||||
}
|
||||
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: $lightColor;
|
||||
}
|
||||
|
||||
.react-datepicker__input-container,
|
||||
.react-datepicker-wrapper {
|
||||
display: block !important;
|
||||
@@ -52,6 +82,10 @@ body,
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.text-ellipsis {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
@@ -69,3 +103,17 @@ body,
|
||||
.progress-bar {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,8 @@ import { homepage } from '../package.json';
|
||||
import container from './container';
|
||||
import store from './container/store';
|
||||
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import './common/react-tagsinput.scss';
|
||||
import './index.scss';
|
||||
|
||||
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { ShlinkMercureInfo } from '../../utils/services/types';
|
||||
import { ShlinkMercureInfo } from '../../api/types';
|
||||
import { GetState } from '../../container/types';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
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 mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
||||
import settingsReducer from '../settings/reducers/settings';
|
||||
import domainsListReducer from '../domains/reducers/domainsList';
|
||||
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
|
||||
import { ShlinkState } from '../container/types';
|
||||
|
||||
export default combineReducers<ShlinkState>({
|
||||
@@ -36,4 +38,6 @@ export default combineReducers<ShlinkState>({
|
||||
tagEdit: tagEditReducer,
|
||||
mercureInfo: mercureInfoReducer,
|
||||
settings: settingsReducer,
|
||||
domainsList: domainsListReducer,
|
||||
visitsOverview: visitsOverviewReducer,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { RouterProps } from 'react-router';
|
||||
import classNames from 'classnames';
|
||||
import { Result } from '../utils/Result';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
@@ -15,19 +15,11 @@ interface CreateServerProps extends RouterProps {
|
||||
createServer: (server: ServerWithId) => void;
|
||||
}
|
||||
|
||||
const Result: FC<{ type: 'success' | 'error' }> = ({ children, type }) => (
|
||||
<div className="row">
|
||||
<div className="col-md-10 offset-md-1">
|
||||
<div
|
||||
className={classNames('p-2 mt-3 text-white text-center', {
|
||||
'bg-main': type === 'success',
|
||||
'bg-danger': type === 'error',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
||||
<Result type={type}>
|
||||
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
||||
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
||||
</Result>
|
||||
);
|
||||
|
||||
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
|
||||
@@ -39,18 +31,22 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
|
||||
const id = uuid();
|
||||
|
||||
createServer({ ...serverData, id });
|
||||
push(`/server/${id}/list-short-urls/1`);
|
||||
push(`/server/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<ServerForm onSubmit={handleSubmit}>
|
||||
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}>
|
||||
<ImportServersBtn onImport={setServersImported} onImportError={setErrorImporting} />
|
||||
<button className="btn btn-outline-primary">Create server</button>
|
||||
</ServerForm>
|
||||
|
||||
{serversImported && <Result type="success">Servers properly imported. You can now select one from the list :)</Result>}
|
||||
{errorImporting && <Result type="error">The servers could not be imported. Make sure the format is correct.</Result>}
|
||||
{(serversImported || errorImporting) && (
|
||||
<div className="mt-4">
|
||||
{serversImported && <ImportResult type="success" />}
|
||||
{errorImporting && <ImportResult type="error" />}
|
||||
</div>
|
||||
)}
|
||||
</NoMenuLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,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>
|
||||
<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.
|
||||
</i>
|
||||
</p>
|
||||
|
||||
@@ -18,12 +18,16 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
|
||||
|
||||
const handleSubmit = (serverData: ServerData) => {
|
||||
editServer(selectedServer.id, serverData);
|
||||
push(`/server/${selectedServer.id}/list-short-urls/1`);
|
||||
push(`/server/${selectedServer.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<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 color="primary">Save</Button>
|
||||
</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,6 +1,8 @@
|
||||
import { isEmpty, values } from 'ramda';
|
||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||
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 { isServerWithId, SelectedServer, ServersMap } from './data';
|
||||
|
||||
@@ -11,10 +13,15 @@ export interface ServersDropdownProps {
|
||||
|
||||
const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||
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 = () => {
|
||||
if (isEmpty(serversList)) {
|
||||
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
|
||||
return createServerItem;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -23,15 +30,16 @@ const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, select
|
||||
<DropdownItem
|
||||
key={id}
|
||||
tag={Link}
|
||||
to={`/server/${id}/list-short-urls/1`}
|
||||
to={`/server/${id}`}
|
||||
active={isServerWithId(selectedServer) && selectedServer.id === id}
|
||||
>
|
||||
{name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownItem divider />
|
||||
{createServerItem}
|
||||
<DropdownItem className="servers-dropdown__export-item" onClick={async () => serversExporter.exportServers()}>
|
||||
Export servers
|
||||
<FontAwesomeIcon icon={exportIcon} /> <span className="ml-1">Export servers</span>
|
||||
</DropdownItem>
|
||||
</>
|
||||
);
|
||||
@@ -39,7 +47,9 @@ const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, select
|
||||
|
||||
return (
|
||||
<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>
|
||||
</UncontrolledDropdown>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
@import '../utils/base';
|
||||
@import '../utils/mixins/vertical-align';
|
||||
@import '../utils/mixins/thin-scroll';
|
||||
|
||||
.servers-list__list-group {
|
||||
.servers-list__list-group.servers-list__list-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.servers-list__list-group:not(.servers-list__list-group--embedded) {
|
||||
max-width: 400px;
|
||||
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .075);
|
||||
}
|
||||
|
||||
.servers-list__server-item.servers-list__server-item {
|
||||
@@ -11,8 +17,29 @@
|
||||
padding: .75rem 2.5rem .75rem 1rem;
|
||||
}
|
||||
|
||||
.servers-list__server-item:hover {
|
||||
background-color: $lightColor;
|
||||
}
|
||||
|
||||
.servers-list__server-item-icon {
|
||||
@include vertical-align();
|
||||
|
||||
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,6 +1,7 @@
|
||||
import { FC } from 'react';
|
||||
import { ListGroup, ListGroupItem } from 'reactstrap';
|
||||
import { Link } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ServerWithId } from './data';
|
||||
@@ -8,22 +9,23 @@ import './ServersListGroup.scss';
|
||||
|
||||
interface ServersListGroup {
|
||||
servers: ServerWithId[];
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
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}
|
||||
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
|
||||
</ListGroupItem>
|
||||
);
|
||||
|
||||
const ServersListGroup: FC<ServersListGroup> = ({ servers, children }) => (
|
||||
const ServersListGroup: FC<ServersListGroup> = ({ servers, children, embedded = false }) => (
|
||||
<>
|
||||
<div className="container">
|
||||
<h5>{children}</h5>
|
||||
</div>
|
||||
{children && <h5 className="mb-md-3">{children}</h5>}
|
||||
{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} />)}
|
||||
</ListGroup>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Message from '../../utils/Message';
|
||||
import ServersListGroup from '../ServersListGroup';
|
||||
import { DeleteServerButtonProps } from '../DeleteServerButton';
|
||||
import { isServerWithId, SelectedServer, ServersMap } from '../data';
|
||||
import NoMenuLayout from '../../common/NoMenuLayout';
|
||||
import './ServerError.scss';
|
||||
|
||||
interface ServerErrorProps {
|
||||
@@ -14,9 +15,9 @@ interface ServerErrorProps {
|
||||
export const ServerError = (DeleteServerButton: FC<DeleteServerButtonProps>): FC<ServerErrorProps> => (
|
||||
{ servers, selectedServer },
|
||||
) => (
|
||||
<div className="server-error__container flex-column">
|
||||
<div className="row w-100 mb-3 mb-md-5">
|
||||
<Message type="error">
|
||||
<NoMenuLayout>
|
||||
<div className="server-error__container flex-column">
|
||||
<Message className="w-100 mb-3 mb-md-5" type="error" fullWidth>
|
||||
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
|
||||
{isServerWithId(selectedServer) && (
|
||||
<>
|
||||
@@ -25,21 +26,21 @@ export const ServerError = (DeleteServerButton: FC<DeleteServerButtonProps>): FC
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</NoMenuLayout>
|
||||
);
|
||||
|
||||
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 { FC, useEffect, useState } from 'react';
|
||||
import { HorizontalFormGroup } from '../../utils/HorizontalFormGroup';
|
||||
import { FC, ReactNode, useEffect, useState } from 'react';
|
||||
import { FormGroupContainer } from '../../utils/FormGroupContainer';
|
||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||
import { ServerData } from '../data';
|
||||
import { SimpleCard } from '../../utils/SimpleCard';
|
||||
import './ServerForm.scss';
|
||||
|
||||
interface ServerFormProps {
|
||||
onSubmit: (server: ServerData) => void;
|
||||
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 [ url, setUrl ] = useState('');
|
||||
const [ apiKey, setApiKey ] = useState('');
|
||||
@@ -21,10 +24,12 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
|
||||
}, [ initialValues ]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HorizontalFormGroup value={name} onChange={setName}>Name</HorizontalFormGroup>
|
||||
<HorizontalFormGroup type="url" value={url} onChange={setUrl}>URL</HorizontalFormGroup>
|
||||
<HorizontalFormGroup value={apiKey} onChange={setApiKey}>API key</HorizontalFormGroup>
|
||||
<form className="server-form" onSubmit={handleSubmit}>
|
||||
<SimpleCard className="mb-4" title={title}>
|
||||
<FormGroupContainer value={name} onChange={setName}>Name</FormGroupContainer>
|
||||
<FormGroupContainer type="url" value={url} onChange={setUrl}>URL</FormGroupContainer>
|
||||
<FormGroupContainer value={apiKey} onChange={setApiKey}>API key</FormGroupContainer>
|
||||
</SimpleCard>
|
||||
|
||||
<div className="text-right">{children}</div>
|
||||
</form>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FC, useEffect } from 'react';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
import Message from '../../utils/Message';
|
||||
import { isNotFoundServer, SelectedServer } from '../data';
|
||||
import NoMenuLayout from '../../common/NoMenuLayout';
|
||||
|
||||
interface WithSelectedServerProps extends RouteComponentProps<{ serverId: string }> {
|
||||
selectServer: (serverId: string) => void;
|
||||
@@ -18,9 +19,9 @@ export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServ
|
||||
|
||||
if (!selectedServer) {
|
||||
return (
|
||||
<div className="row">
|
||||
<NoMenuLayout>
|
||||
<Message loading />
|
||||
</div>
|
||||
</NoMenuLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListPara
|
||||
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
||||
import { SelectedServer } from '../data';
|
||||
import { GetState } from '../../container/types';
|
||||
import { ShlinkHealth } from '../../utils/services/types';
|
||||
import { ShlinkHealth } from '../../api/types';
|
||||
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 */
|
||||
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { CsvJson } from 'csvjson';
|
||||
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 {
|
||||
public constructor(private readonly csvjson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
||||
|
||||
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
||||
if (!file || file.type !== CSV_MIME_TYPE) {
|
||||
if (!isCsv(file)) {
|
||||
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 { ConnectDecorator } from '../../container/types';
|
||||
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
|
||||
import { Overview } from '../Overview';
|
||||
import ServersImporter from './ServersImporter';
|
||||
import ServersExporter from './ServersExporter';
|
||||
|
||||
@@ -43,6 +44,12 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||
bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton');
|
||||
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
|
||||
bottle.constant('csvjson', csvjson);
|
||||
bottle.constant('fileReaderFactory', () => new FileReader());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Card, CardBody, CardHeader, FormGroup, Input } from 'reactstrap';
|
||||
import { FormGroup, Input } from 'reactstrap';
|
||||
import classNames from 'classnames';
|
||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import { Settings } from './reducers/settings';
|
||||
|
||||
interface RealTimeUpdatesProps {
|
||||
@@ -14,39 +15,36 @@ const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
|
||||
const RealTimeUpdates = (
|
||||
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
|
||||
) => (
|
||||
<Card>
|
||||
<CardHeader>Real-time updates</CardHeader>
|
||||
<CardBody>
|
||||
<FormGroup>
|
||||
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
|
||||
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
|
||||
</ToggleSwitch>
|
||||
</FormGroup>
|
||||
<FormGroup className="mb-0">
|
||||
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
|
||||
Real-time updates frequency (in minutes):
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Immediate"
|
||||
disabled={!realTimeUpdates.enabled}
|
||||
value={intervalValue(realTimeUpdates.interval)}
|
||||
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
|
||||
/>
|
||||
{realTimeUpdates.enabled && (
|
||||
<small className="form-text text-muted">
|
||||
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
|
||||
<span>
|
||||
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
|
||||
</span>
|
||||
)}
|
||||
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
|
||||
</small>
|
||||
)}
|
||||
</FormGroup>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<SimpleCard title="Real-time updates">
|
||||
<FormGroup>
|
||||
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
|
||||
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
|
||||
</ToggleSwitch>
|
||||
</FormGroup>
|
||||
<FormGroup className="mb-0">
|
||||
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
|
||||
Real-time updates frequency (in minutes):
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Immediate"
|
||||
disabled={!realTimeUpdates.enabled}
|
||||
value={intervalValue(realTimeUpdates.interval)}
|
||||
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
|
||||
/>
|
||||
{realTimeUpdates.enabled && (
|
||||
<small className="form-text text-muted">
|
||||
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
|
||||
<span>
|
||||
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
|
||||
</span>
|
||||
)}
|
||||
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
|
||||
</small>
|
||||
)}
|
||||
</FormGroup>
|
||||
</SimpleCard>
|
||||
);
|
||||
|
||||
export default RealTimeUpdates;
|
||||
|
||||
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 { FC, useState } from 'react';
|
||||
import { Collapse, FormGroup, Input } from 'reactstrap';
|
||||
import { Button, FormGroup, Input } from 'reactstrap';
|
||||
import { InputType } from 'reactstrap/lib/Input';
|
||||
import * as m from 'moment';
|
||||
import DateInput, { DateInputProps } from '../utils/DateInput';
|
||||
import Checkbox from '../utils/Checkbox';
|
||||
import { versionMatch, Versions } from '../utils/helpers/version';
|
||||
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||
import { formatIsoDate } from '../utils/helpers/date';
|
||||
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||
import { DomainSelectorProps } from '../domains/DomainSelector';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import { ShortUrlData } from './data';
|
||||
import { ShortUrlCreation } from './reducers/shortUrlCreation';
|
||||
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
||||
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;
|
||||
selectedServer: SelectedServer;
|
||||
createShortUrl: Function;
|
||||
createShortUrl: (data: ShortUrlData) => Promise<void>;
|
||||
resetCreateShortUrl: () => void;
|
||||
}
|
||||
|
||||
export const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||
|
||||
const initialState: ShortUrlData = {
|
||||
longUrl: '',
|
||||
tags: [],
|
||||
@@ -37,6 +41,7 @@ const initialState: ShortUrlData = {
|
||||
validUntil: undefined,
|
||||
maxVisits: undefined,
|
||||
findIfExists: false,
|
||||
validateUrl: true,
|
||||
};
|
||||
|
||||
type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits';
|
||||
@@ -46,17 +51,23 @@ const CreateShortUrl = (
|
||||
TagsSelector: FC<TagsSelectorProps>,
|
||||
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
|
||||
ForServerVersion: FC<Versions>,
|
||||
) => ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }: CreateShortUrlProps) => {
|
||||
DomainSelector: FC<DomainSelectorProps>,
|
||||
) => ({
|
||||
createShortUrl,
|
||||
shortUrlCreationResult,
|
||||
resetCreateShortUrl,
|
||||
selectedServer,
|
||||
basicMode = false,
|
||||
}: CreateShortUrlConnectProps) => {
|
||||
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
|
||||
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
|
||||
|
||||
const changeTags = (tags: string[]) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
|
||||
const reset = () => setShortUrlCreation(initialState);
|
||||
const save = handleEventPreventingDefault(() => {
|
||||
const shortUrlData = {
|
||||
...shortUrlCreation,
|
||||
validSince: formatIsoDate(shortUrlCreation.validSince),
|
||||
validUntil: formatIsoDate(shortUrlCreation.validUntil),
|
||||
validSince: formatIsoDate(shortUrlCreation.validSince) ?? undefined,
|
||||
validUntil: formatIsoDate(shortUrlCreation.validUntil) ?? undefined,
|
||||
};
|
||||
|
||||
createShortUrl(shortUrlData).then(reset).catch(() => {});
|
||||
@@ -84,93 +95,119 @@ const CreateShortUrl = (
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const currentServerVersion = isReachableServer(selectedServer) ? selectedServer.version : '';
|
||||
const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
|
||||
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
|
||||
|
||||
return (
|
||||
<form onSubmit={save}>
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control form-control-lg"
|
||||
const basicComponents = (
|
||||
<>
|
||||
<FormGroup>
|
||||
<Input
|
||||
bsSize="lg"
|
||||
type="url"
|
||||
placeholder="Insert the URL to be shortened"
|
||||
placeholder="URL to be shortened"
|
||||
required
|
||||
value={shortUrlCreation.longUrl}
|
||||
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, longUrl: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
<Collapse isOpen={moreOptionsVisible}>
|
||||
<div className="form-group">
|
||||
<TagsSelector tags={shortUrlCreation.tags ?? []} onChange={changeTags} />
|
||||
</div>
|
||||
<FormGroup>
|
||||
<TagsSelector tags={shortUrlCreation.tags ?? []} onChange={changeTags} />
|
||||
</FormGroup>
|
||||
</>
|
||||
);
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
|
||||
disabled: hasValue(shortUrlCreation.shortCodeLength),
|
||||
})}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
||||
min: 4,
|
||||
disabled: disableShortCodeLength || hasValue(shortUrlCreation.customSlug),
|
||||
...disableShortCodeLength && {
|
||||
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('domain', 'Domain', 'text', {
|
||||
disabled: disableDomain,
|
||||
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
const currentServerVersion = isReachableServer(selectedServer) ? selectedServer.version : '';
|
||||
const showDomainSelector = versionMatch(currentServerVersion, { minVersion: '2.4.0' });
|
||||
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil as m.Moment | undefined })}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince as m.Moment | undefined })}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<form className="create-short-url" onSubmit={save}>
|
||||
{basicMode && basicComponents}
|
||||
{!basicMode && (
|
||||
<>
|
||||
<SimpleCard title="Basic options" className="mb-3">
|
||||
{basicComponents}
|
||||
</SimpleCard>
|
||||
|
||||
<ForServerVersion minVersion="1.16.0">
|
||||
<div className="mb-4 text-right">
|
||||
<Checkbox
|
||||
className="mr-2"
|
||||
checked={shortUrlCreation.findIfExists}
|
||||
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
|
||||
>
|
||||
Use existing URL if found
|
||||
</Checkbox>
|
||||
<UseExistingIfFoundInfoIcon />
|
||||
</div>
|
||||
</ForServerVersion>
|
||||
</Collapse>
|
||||
<div className="row">
|
||||
<div className="col-sm-6 mb-3">
|
||||
<SimpleCard title="Customize the short URL">
|
||||
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
|
||||
disabled: hasValue(shortUrlCreation.shortCodeLength),
|
||||
})}
|
||||
{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',
|
||||
},
|
||||
})}
|
||||
{!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text')}
|
||||
{showDomainSelector && (
|
||||
<FormGroup>
|
||||
<DomainSelector
|
||||
value={shortUrlCreation.domain}
|
||||
onChange={(domain?: string) => setShortUrlCreation({ ...shortUrlCreation, domain })}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</SimpleCard>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={toggleMoreOptionsVisible}>
|
||||
<FontAwesomeIcon icon={moreOptionsVisible ? upIcon : downIcon} />
|
||||
|
||||
{moreOptionsVisible ? 'Less' : 'More'} options
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary float-right"
|
||||
<div className="col-sm-6 mb-3">
|
||||
<SimpleCard title="Limit access to the short URL">
|
||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil as m.Moment | undefined })}
|
||||
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince as m.Moment | undefined })}
|
||||
</SimpleCard>
|
||||
</div>
|
||||
</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)}
|
||||
className="btn-xs-block"
|
||||
>
|
||||
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
|
||||
<CreateShortUrlResult
|
||||
{...shortUrlCreationResult}
|
||||
resetCreateShortUrl={resetCreateShortUrl}
|
||||
canBeClosed={basicMode}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.short-urls-paginator {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background-color: rgba(white, .8);
|
||||
background-color: rgba(255, 255, 255, .5);
|
||||
padding: .75rem 0;
|
||||
border-top: 1px solid rgba(black, .125);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||
import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination';
|
||||
import { ShlinkPaginator } from '../utils/services/types';
|
||||
import { ShlinkPaginator } from '../api/types';
|
||||
import './Paginator.scss';
|
||||
|
||||
interface PaginatorProps {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { FC } from 'react';
|
||||
import { isEmpty, pipe } from 'ramda';
|
||||
import moment from 'moment';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import Tag from '../tags/helpers/Tag';
|
||||
import DateRangeRow from '../utils/DateRangeRow';
|
||||
import { formatDate } from '../utils/helpers/date';
|
||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||
import { formatIsoDate } from '../utils/helpers/date';
|
||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||
import { Versions } from '../utils/helpers/version';
|
||||
import { DateRange } from '../utils/dates/types';
|
||||
import { ShortUrlsListParams } from './reducers/shortUrlsListParams';
|
||||
import './SearchBar.scss';
|
||||
|
||||
@@ -19,13 +18,14 @@ interface SearchBarProps {
|
||||
|
||||
const dateOrNull = (date?: string) => date ? moment(date) : null;
|
||||
|
||||
const SearchBar = (colorGenerator: ColorGenerator, ForServerVersion: FC<Versions>) => (
|
||||
{ listShortUrls, shortUrlsListParams }: SearchBarProps,
|
||||
) => {
|
||||
const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrlsListParams }: SearchBarProps) => {
|
||||
const selectedTags = shortUrlsListParams.tags ?? [];
|
||||
const setDate = (dateName: 'startDate' | 'endDate') => pipe(
|
||||
formatDate(),
|
||||
(date) => listShortUrls({ ...shortUrlsListParams, [dateName]: date }),
|
||||
const setDates = pipe(
|
||||
({ startDate, endDate }: DateRange) => ({
|
||||
startDate: formatIsoDate(startDate) ?? undefined,
|
||||
endDate: formatIsoDate(endDate) ?? undefined,
|
||||
}),
|
||||
(dates) => listShortUrls({ ...shortUrlsListParams, ...dates }),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -36,20 +36,20 @@ const SearchBar = (colorGenerator: ColorGenerator, ForServerVersion: FC<Versions
|
||||
}
|
||||
/>
|
||||
|
||||
<ForServerVersion minVersion="1.21.0">
|
||||
<div className="mt-3">
|
||||
<div className="row">
|
||||
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
|
||||
<DateRangeRow
|
||||
startDate={dateOrNull(shortUrlsListParams.startDate)}
|
||||
endDate={dateOrNull(shortUrlsListParams.endDate)}
|
||||
onStartDateChange={setDate('startDate')}
|
||||
onEndDateChange={setDate('endDate')}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="row">
|
||||
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
|
||||
<DateRangeSelector
|
||||
defaultText="All short URLs"
|
||||
initialDateRange={{
|
||||
startDate: dateOrNull(shortUrlsListParams.startDate),
|
||||
endDate: dateOrNull(shortUrlsListParams.endDate),
|
||||
}}
|
||||
onDatesChange={setDates}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ForServerVersion>
|
||||
</div>
|
||||
|
||||
{!isEmpty(selectedTags) && (
|
||||
<h4 className="search-bar__selected-tag mt-3">
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { ShlinkShortUrlsResponse } from '../utils/services/types';
|
||||
import Paginator from './Paginator';
|
||||
import { ShortUrlsListProps, WithList } from './ShortUrlsList';
|
||||
import { ShortUrlsListProps } from './ShortUrlsList';
|
||||
|
||||
interface ShortUrlsProps extends ShortUrlsListProps {
|
||||
shortUrlsList?: ShlinkShortUrlsResponse;
|
||||
}
|
||||
|
||||
const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps & WithList>) => (props: ShortUrlsProps) => {
|
||||
const { match, shortUrlsList } = props;
|
||||
const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps>) => (props: ShortUrlsListProps) => {
|
||||
const { match } = props;
|
||||
const { page = '1', serverId = '' } = match?.params ?? {};
|
||||
const { data = [], pagination } = shortUrlsList ?? {};
|
||||
const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`);
|
||||
|
||||
// Using a key on a component makes react to create a new instance every time the key changes
|
||||
@@ -22,10 +15,7 @@ const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps & WithLis
|
||||
return (
|
||||
<>
|
||||
<div className="form-group"><SearchBar /></div>
|
||||
<div>
|
||||
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
|
||||
<Paginator paginator={pagination} serverId={serverId} />
|
||||
</div>
|
||||
<ShortUrlsList {...props} key={urlsListKey} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { head, isEmpty, keys, values } from 'ramda';
|
||||
import { head, keys, values } from 'ramda';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import qs from 'qs';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
import { Card } from 'reactstrap';
|
||||
import SortingDropdown from '../utils/SortingDropdown';
|
||||
import { determineOrderDir, OrderDir } from '../utils/utils';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { parseQuery } from '../utils/helpers/query';
|
||||
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 { ShortUrlsTableProps } from './ShortUrlsTable';
|
||||
import Paginator from './Paginator';
|
||||
import './ShortUrlsList.scss';
|
||||
|
||||
interface RouteParams {
|
||||
@@ -19,33 +20,29 @@ interface RouteParams {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export interface WithList {
|
||||
shortUrlsList: ShortUrl[];
|
||||
}
|
||||
|
||||
export interface ShortUrlsListProps extends ShortUrlsListState, RouteComponentProps<RouteParams> {
|
||||
export interface ShortUrlsListProps extends RouteComponentProps<RouteParams> {
|
||||
selectedServer: SelectedServer;
|
||||
shortUrlsList: ShortUrlsListState;
|
||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
||||
shortUrlsListParams: ShortUrlsListParams;
|
||||
resetShortUrlParams: () => void;
|
||||
}
|
||||
|
||||
const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => boundToMercureHub(({
|
||||
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercureHub(({
|
||||
listShortUrls,
|
||||
resetShortUrlParams,
|
||||
shortUrlsListParams,
|
||||
match,
|
||||
location,
|
||||
loading,
|
||||
error,
|
||||
shortUrlsList,
|
||||
selectedServer,
|
||||
}: ShortUrlsListProps & WithList) => {
|
||||
}: ShortUrlsListProps) => {
|
||||
const { orderBy } = shortUrlsListParams;
|
||||
const [ order, setOrder ] = useState<{ orderField?: OrderableFields; orderDir?: OrderDir }>({
|
||||
orderField: orderBy && (head(keys(orderBy)) as OrderableFields),
|
||||
orderDir: orderBy && head(values(orderBy)),
|
||||
});
|
||||
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
||||
const refreshList = (extraParams: ShortUrlsListParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
|
||||
const handleOrderBy = (orderField?: OrderableFields, orderDir?: 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(() => {
|
||||
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
|
||||
const tags = query.tag ? [ query.tag as string ] : shortUrlsListParams.tags;
|
||||
const { tag } = parseQuery<{ tag?: string }>(location.search);
|
||||
const tags = tag ? [ tag ] : shortUrlsListParams.tags;
|
||||
|
||||
refreshList({ page: match.params.page, tags });
|
||||
refreshList({ page: match.params.page, tags, itemsPerPage: undefined });
|
||||
|
||||
return resetShortUrlParams;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-block d-md-none mb-3">
|
||||
<div className="d-block d-lg-none mb-3">
|
||||
<SortingDropdown
|
||||
items={SORTABLE_FIELDS}
|
||||
orderField={order.orderField}
|
||||
@@ -116,44 +86,16 @@ const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => boundToMercureHub
|
||||
onChange={handleOrderBy}
|
||||
/>
|
||||
</div>
|
||||
<table className="table table-striped table-hover">
|
||||
<thead className="short-urls-list__header">
|
||||
<tr>
|
||||
<th
|
||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
||||
onClick={orderByColumn('dateCreated')}
|
||||
>
|
||||
{renderOrderIcon('dateCreated')}
|
||||
Created at
|
||||
</th>
|
||||
<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>
|
||||
<Card body className="pb-1">
|
||||
<ShortUrlsTable
|
||||
orderByColumn={orderByColumn}
|
||||
renderOrderIcon={renderOrderIcon}
|
||||
selectedServer={selectedServer}
|
||||
shortUrlsList={shortUrlsList}
|
||||
onTagClick={(tag) => refreshList({ tags: [ ...shortUrlsListParams.tags ?? [], tag ] })}
|
||||
/>
|
||||
<Paginator paginator={pagination} serverId={isReachableServer(selectedServer) ? selectedServer.id : ''} />
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}, () => '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>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ export interface ShortUrlData {
|
||||
validUntil?: m.Moment | string;
|
||||
maxVisits?: number;
|
||||
findIfExists?: boolean;
|
||||
validateUrl?: boolean;
|
||||
}
|
||||
|
||||
export interface ShortUrl {
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
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 { isNil } from 'ramda';
|
||||
import { useEffect } from 'react';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import { Card, CardBody, Tooltip } from 'reactstrap';
|
||||
import { Tooltip } from 'reactstrap';
|
||||
import { ShortUrlCreation } from '../reducers/shortUrlCreation';
|
||||
import { StateFlagTimeout } from '../../utils/helpers/hooks';
|
||||
import { Result } from '../../utils/Result';
|
||||
import './CreateShortUrlResult.scss';
|
||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||
|
||||
export interface CreateShortUrlResultProps extends ShortUrlCreation {
|
||||
resetCreateShortUrl: () => void;
|
||||
canBeClosed?: boolean;
|
||||
}
|
||||
|
||||
const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
|
||||
{ error, result, resetCreateShortUrl }: CreateShortUrlResultProps,
|
||||
{ error, errorData, result, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps,
|
||||
) => {
|
||||
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout();
|
||||
|
||||
@@ -23,9 +27,10 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card body color="danger" inverse className="bg-danger mt-3">
|
||||
An error occurred while creating the URL :(
|
||||
</Card>
|
||||
<Result type="error" className="mt-3">
|
||||
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />}
|
||||
<ShlinkApiError errorData={errorData} fallbackMessage="An error occurred while creating the URL :(" />
|
||||
</Result>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,25 +41,24 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
|
||||
const { shortUrl } = result;
|
||||
|
||||
return (
|
||||
<Card inverse className="bg-main mt-3">
|
||||
<CardBody>
|
||||
<b>Great!</b> The short URL is <b>{shortUrl}</b>
|
||||
<Result type="success" className="mt-3">
|
||||
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />}
|
||||
<b>Great!</b> The short URL is <b>{shortUrl}</b>
|
||||
|
||||
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
|
||||
<button
|
||||
className="btn btn-light btn-sm create-short-url-result__copy-btn"
|
||||
id="copyBtn"
|
||||
type="button"
|
||||
>
|
||||
<FontAwesomeIcon icon={copyIcon} /> Copy
|
||||
</button>
|
||||
</CopyToClipboard>
|
||||
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
|
||||
<button
|
||||
className="btn btn-light btn-sm create-short-url-result__copy-btn"
|
||||
id="copyBtn"
|
||||
type="button"
|
||||
>
|
||||
<FontAwesomeIcon icon={copyIcon} /> Copy
|
||||
</button>
|
||||
</CopyToClipboard>
|
||||
|
||||
<Tooltip placement="left" isOpen={showCopyTooltip} target="copyBtn">
|
||||
Copied!
|
||||
</Tooltip>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Tooltip placement="left" isOpen={showCopyTooltip} target="copyBtn">
|
||||
Copied!
|
||||
</Tooltip>
|
||||
</Result>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ import { identity, pipe } from 'ramda';
|
||||
import { ShortUrlDeletion } from '../reducers/shortUrlDeletion';
|
||||
import { ShortUrlModalProps } from '../data';
|
||||
import { handleEventPreventingDefault, OptionalString } from '../../utils/utils';
|
||||
|
||||
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
|
||||
import { Result } from '../../utils/Result';
|
||||
import { isInvalidDeletionError } from '../../api/utils';
|
||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||
|
||||
interface DeleteShortUrlModalConnectProps extends ShortUrlModalProps {
|
||||
shortUrlDeletion: ShortUrlDeletion;
|
||||
@@ -21,9 +22,6 @@ const DeleteShortUrlModal = (
|
||||
useEffect(() => resetDeleteShortUrl, []);
|
||||
|
||||
const { error, errorData } = shortUrlDeletion;
|
||||
const errorCode = error && (errorData?.type || errorData?.error);
|
||||
const hasThresholdError = errorCode === THRESHOLD_REACHED;
|
||||
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
|
||||
const close = pipe(resetDeleteShortUrl, toggle);
|
||||
const handleDeleteUrl = handleEventPreventingDefault(() => {
|
||||
const { shortCode, domain } = shortUrl;
|
||||
@@ -42,25 +40,20 @@ const DeleteShortUrlModal = (
|
||||
<ModalBody>
|
||||
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
|
||||
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
|
||||
<p>Write <b>{shortUrl.shortCode}</b> to confirm deletion.</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Insert the short code of the URL"
|
||||
placeholder={`Insert the short code (${shortUrl.shortCode})`}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
/>
|
||||
|
||||
{hasThresholdError && (
|
||||
<div className="p-2 mt-2 bg-warning text-center">
|
||||
{errorData?.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`}
|
||||
{!errorData?.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
|
||||
</div>
|
||||
)}
|
||||
{hasErrorOtherThanThreshold && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while deleting the URL :(
|
||||
</div>
|
||||
{error && (
|
||||
<Result type={isInvalidDeletionError(errorData) ? 'warning' : 'error'} small className="mt-2">
|
||||
<ShlinkApiError errorData={errorData} fallbackMessage="Something went wrong while deleting the URL :(" />
|
||||
</Result>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -10,6 +10,8 @@ import DateInput from '../../utils/DateInput';
|
||||
import { formatIsoDate } from '../../utils/helpers/date';
|
||||
import { ShortUrl, ShortUrlMeta, ShortUrlModalProps } from '../data';
|
||||
import { handleEventPreventingDefault, Nullable, OptionalString } from '../../utils/utils';
|
||||
import { Result } from '../../utils/Result';
|
||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||
|
||||
interface EditMetaModalConnectProps extends ShortUrlModalProps {
|
||||
shortUrlMeta: ShortUrlMetaEdition;
|
||||
@@ -26,7 +28,7 @@ const dateOrNull = (shortUrl: ShortUrl | undefined, dateName: 'validSince' | 'va
|
||||
const EditMetaModal = (
|
||||
{ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }: EditMetaModalConnectProps,
|
||||
) => {
|
||||
const { saving, error } = shortUrlMeta;
|
||||
const { saving, error, errorData } = shortUrlMeta;
|
||||
const url = shortUrl && (shortUrl.shortUrl || '');
|
||||
const [ validSince, setValidSince ] = useState(dateOrNull(shortUrl, 'validSince'));
|
||||
const [ validUntil, setValidUntil ] = useState(dateOrNull(shortUrl, 'validUntil'));
|
||||
@@ -77,10 +79,14 @@ const EditMetaModal = (
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setMaxVisits(Number(e.target.value))}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{error && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while saving the metadata :(
|
||||
</div>
|
||||
<Result type="error" small className="mt-2">
|
||||
<ShlinkApiError
|
||||
errorData={errorData}
|
||||
fallbackMessage="Something went wrong while saving the metadata :("
|
||||
/>
|
||||
</Result>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ExternalLink } from 'react-external-link';
|
||||
import { ShortUrlEdition } from '../reducers/shortUrlEdition';
|
||||
import { handleEventPreventingDefault, hasValue, OptionalString } from '../../utils/utils';
|
||||
import { ShortUrlModalProps } from '../data';
|
||||
import { Result } from '../../utils/Result';
|
||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||
|
||||
interface EditShortUrlModalProps extends ShortUrlModalProps {
|
||||
shortUrlEdition: ShortUrlEdition;
|
||||
@@ -11,14 +13,14 @@ interface EditShortUrlModalProps extends ShortUrlModalProps {
|
||||
}
|
||||
|
||||
const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShortUrl }: EditShortUrlModalProps) => {
|
||||
const { saving, error } = shortUrlEdition;
|
||||
const { saving, error, errorData } = shortUrlEdition;
|
||||
const url = shortUrl?.shortUrl ?? '';
|
||||
const [ longUrl, setLongUrl ] = useState(shortUrl.longUrl);
|
||||
|
||||
const doEdit = async () => editShortUrl(shortUrl.shortCode, shortUrl.domain, longUrl).then(toggle);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
|
||||
<ModalHeader toggle={toggle}>
|
||||
Edit long URL for <ExternalLink href={url} />
|
||||
</ModalHeader>
|
||||
@@ -34,9 +36,12 @@ const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShor
|
||||
/>
|
||||
</FormGroup>
|
||||
{error && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while saving the long URL :(
|
||||
</div>
|
||||
<Result type="error" small className="mt-2">
|
||||
<ShlinkApiError
|
||||
errorData={errorData}
|
||||
fallbackMessage="Something went wrong while saving the long URL :("
|
||||
/>
|
||||
</Result>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { ShortUrlTags } from '../reducers/shortUrlTags';
|
||||
import { ShortUrlModalProps } from '../data';
|
||||
import { OptionalString } from '../../utils/utils';
|
||||
import { TagsSelectorProps } from '../../tags/helpers/TagsSelector';
|
||||
import { Result } from '../../utils/Result';
|
||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||
|
||||
interface EditTagsModalProps extends ShortUrlModalProps {
|
||||
shortUrlTags: ShortUrlTags;
|
||||
@@ -19,6 +21,7 @@ const EditTagsModal = (TagsSelector: FC<TagsSelectorProps>) => (
|
||||
|
||||
useEffect(() => resetShortUrlsTags, []);
|
||||
|
||||
const { saving, error, errorData } = shortUrlTags;
|
||||
const url = shortUrl?.shortUrl ?? '';
|
||||
const saveTags = async () => editShortUrlTags(shortUrl.shortCode, shortUrl.domain, selectedTags)
|
||||
.then(toggle)
|
||||
@@ -31,16 +34,16 @@ const EditTagsModal = (TagsSelector: FC<TagsSelectorProps>) => (
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<TagsSelector tags={selectedTags} onChange={setSelectedTags} />
|
||||
{shortUrlTags.error && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while saving the tags :(
|
||||
</div>
|
||||
{error && (
|
||||
<Result type="error" small className="mt-2">
|
||||
<ShlinkApiError errorData={errorData} fallbackMessage="Something went wrong while saving the tags :(" />
|
||||
</Result>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||
<button className="btn btn-primary" type="button" disabled={shortUrlTags.saving} onClick={saveTags}>
|
||||
{shortUrlTags.saving ? 'Saving tags...' : 'Save tags'}
|
||||
<button className="btn btn-primary" type="button" disabled={saving} onClick={saveTags}>
|
||||
{saving ? 'Saving tags...' : 'Save tags'}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</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,20 +0,0 @@
|
||||
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;
|
||||
@@ -2,7 +2,7 @@
|
||||
@import '../../utils/mixins/vertical-align';
|
||||
|
||||
.short-urls-row {
|
||||
@media (max-width: $smMax) {
|
||||
@media (max-width: $responsiveTableBreakpoint) {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid $lightGrey;
|
||||
@@ -13,7 +13,7 @@
|
||||
.short-urls-row__cell.short-urls-row__cell {
|
||||
vertical-align: middle !important;
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
@media (max-width: $responsiveTableBreakpoint) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
&:before {
|
||||
content: attr(data-th);
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
@@ -55,9 +55,10 @@
|
||||
|
||||
.short-urls-row__copy-hint {
|
||||
@include vertical-align(translateX(10px));
|
||||
|
||||
box-shadow: 0 3px 15px rgba(0, 0, 0, .25);
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
@media (max-width: $responsiveTableBreakpoint) {
|
||||
@include vertical-align(translateX(calc(-100% - 20px)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ExternalLink } from 'react-external-link';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import { ShortUrlsListParams } from '../reducers/shortUrlsListParams';
|
||||
import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||
import { StateFlagTimeout } from '../../utils/helpers/hooks';
|
||||
import Tag from '../../tags/helpers/Tag';
|
||||
@@ -16,8 +15,7 @@ import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu';
|
||||
import './ShortUrlsRow.scss';
|
||||
|
||||
export interface ShortUrlsRowProps {
|
||||
refreshList: Function;
|
||||
shortUrlsListParams: ShortUrlsListParams;
|
||||
onTagClick?: (tag: string) => void;
|
||||
selectedServer: SelectedServer;
|
||||
shortUrl: ShortUrl;
|
||||
}
|
||||
@@ -26,7 +24,7 @@ const ShortUrlsRow = (
|
||||
ShortUrlsRowMenu: FC<ShortUrlsRowMenuProps>,
|
||||
colorGenerator: ColorGenerator,
|
||||
useStateFlagTimeout: StateFlagTimeout,
|
||||
) => ({ shortUrl, selectedServer, refreshList, shortUrlsListParams }: ShortUrlsRowProps) => {
|
||||
) => ({ shortUrl, selectedServer, onTagClick }: ShortUrlsRowProps) => {
|
||||
const [ copiedToClipboard, setCopiedToClipboard ] = useStateFlagTimeout();
|
||||
const [ active, setActive ] = useStateFlagTimeout(false, 500);
|
||||
const isFirstRun = useRef(true);
|
||||
@@ -36,14 +34,12 @@ const ShortUrlsRow = (
|
||||
return <i className="indivisible"><small>No tags</small></i>;
|
||||
}
|
||||
|
||||
const selectedTags = shortUrlsListParams.tags ?? [];
|
||||
|
||||
return tags.map((tag) => (
|
||||
<Tag
|
||||
colorGenerator={colorGenerator}
|
||||
key={tag}
|
||||
text={tag}
|
||||
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
|
||||
onClick={() => onTagClick?.(tag)}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { faImage as pictureIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import {
|
||||
faTags as tagsIcon,
|
||||
faChartPie as pieChartIcon,
|
||||
@@ -15,7 +14,6 @@ import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { ShortUrl, ShortUrlModalProps } from '../data';
|
||||
import { Versions } from '../../utils/helpers/version';
|
||||
import { SelectedServer } from '../../servers/data';
|
||||
import PreviewModal from './PreviewModal';
|
||||
import QrCodeModal from './QrCodeModal';
|
||||
import VisitStatsLink from './VisitStatsLink';
|
||||
import './ShortUrlsRowMenu.scss';
|
||||
@@ -35,7 +33,6 @@ const ShortUrlsRowMenu = (
|
||||
) => ({ shortUrl, selectedServer }: ShortUrlsRowMenuProps) => {
|
||||
const [ isOpen, toggle ] = useToggle();
|
||||
const [ isQrModalOpen, toggleQrCode ] = useToggle();
|
||||
const [ isPreviewModalOpen, togglePreview ] = useToggle();
|
||||
const [ isTagsModalOpen, toggleTags ] = useToggle();
|
||||
const [ isMetaModalOpen, toggleMeta ] = useToggle();
|
||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||
@@ -56,12 +53,10 @@ const ShortUrlsRowMenu = (
|
||||
</DropdownItem>
|
||||
<EditTagsModal shortUrl={shortUrl} isOpen={isTagsModalOpen} toggle={toggleTags} />
|
||||
|
||||
<ForServerVersion minVersion="1.18.0">
|
||||
<DropdownItem onClick={toggleMeta}>
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
|
||||
</DropdownItem>
|
||||
<EditMetaModal shortUrl={shortUrl} isOpen={isMetaModalOpen} toggle={toggleMeta} />
|
||||
</ForServerVersion>
|
||||
<DropdownItem onClick={toggleMeta}>
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
|
||||
</DropdownItem>
|
||||
<EditMetaModal shortUrl={shortUrl} isOpen={isMetaModalOpen} toggle={toggleMeta} />
|
||||
|
||||
<ForServerVersion minVersion="2.1.0">
|
||||
<DropdownItem onClick={toggleEdit}>
|
||||
@@ -75,13 +70,6 @@ const ShortUrlsRowMenu = (
|
||||
</DropdownItem>
|
||||
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
|
||||
|
||||
<ForServerVersion maxVersion="1.x">
|
||||
<DropdownItem onClick={togglePreview}>
|
||||
<FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview
|
||||
</DropdownItem>
|
||||
<PreviewModal shortUrl={shortUrl} isOpen={isPreviewModalOpen} toggle={togglePreview} />
|
||||
</ForServerVersion>
|
||||
|
||||
<DropdownItem divider />
|
||||
|
||||
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { Action, Dispatch } from 'redux';
|
||||
import { GetState } from '../../container/types';
|
||||
import { ShortUrl, ShortUrlData } from '../data';
|
||||
import { buildReducer, buildActionCreator } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
|
||||
@@ -15,21 +17,26 @@ export interface ShortUrlCreation {
|
||||
result: ShortUrl | null;
|
||||
saving: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface CreateShortUrlAction extends Action<string> {
|
||||
result: ShortUrl;
|
||||
}
|
||||
|
||||
export interface CreateShortUrlFailedAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlCreation = {
|
||||
result: null,
|
||||
saving: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<ShortUrlCreation, CreateShortUrlAction>({
|
||||
export default buildReducer<ShortUrlCreation, CreateShortUrlAction & CreateShortUrlFailedAction>({
|
||||
[CREATE_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||
[CREATE_SHORT_URL_ERROR]: (state) => ({ ...state, saving: false, error: true }),
|
||||
[CREATE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
||||
[CREATE_SHORT_URL]: (_, { result }) => ({ result, saving: false, error: false }),
|
||||
[RESET_CREATE_SHORT_URL]: () => initialState,
|
||||
}, initialState);
|
||||
@@ -46,7 +53,7 @@ export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
||||
|
||||
dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result });
|
||||
} catch (e) {
|
||||
dispatch({ type: CREATE_SHORT_URL_ERROR });
|
||||
dispatch<CreateShortUrlFailedAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||
import { ProblemDetailsError } from '../../utils/services/types';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { GetState } from '../../container/types';
|
||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
|
||||
@@ -18,13 +19,13 @@ export interface ShortUrlDeletion {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
interface DeleteShortUrlAction extends Action<string> {
|
||||
export interface DeleteShortUrlAction extends Action<string> {
|
||||
shortCode: string;
|
||||
domain?: string | null;
|
||||
}
|
||||
|
||||
interface DeleteShortUrlErrorAction extends Action<string> {
|
||||
errorData: ProblemDetailsError;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlDeletion = {
|
||||
@@ -51,7 +52,7 @@ export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
||||
await deleteShortUrl(shortCode, domain);
|
||||
dispatch<DeleteShortUrlAction>({ type: SHORT_URL_DELETED, shortCode, domain });
|
||||
} catch (e) {
|
||||
dispatch<DeleteShortUrlErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: e.response.data });
|
||||
dispatch<DeleteShortUrlErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { GetState } from '../../container/types';
|
||||
import { OptionalString } from '../../utils/utils';
|
||||
import { ShortUrlIdentifier } from '../data';
|
||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
|
||||
@@ -16,12 +18,17 @@ export interface ShortUrlEdition {
|
||||
longUrl: string | null;
|
||||
saving: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface ShortUrlEditedAction extends Action<string>, ShortUrlIdentifier {
|
||||
longUrl: string;
|
||||
}
|
||||
|
||||
export interface ShortUrlEditionFailedAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlEdition = {
|
||||
shortCode: null,
|
||||
longUrl: null,
|
||||
@@ -29,9 +36,9 @@ const initialState: ShortUrlEdition = {
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<ShortUrlEdition, ShortUrlEditedAction>({
|
||||
export default buildReducer<ShortUrlEdition, ShortUrlEditedAction & ShortUrlEditionFailedAction>({
|
||||
[EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||
[EDIT_SHORT_URL_ERROR]: (state) => ({ ...state, saving: false, error: true }),
|
||||
[EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
||||
[SHORT_URL_EDITED]: (_, { shortCode, longUrl }) => ({ shortCode, longUrl, saving: false, error: false }),
|
||||
}, initialState);
|
||||
|
||||
@@ -47,7 +54,7 @@ export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||
await updateShortUrlMeta(shortCode, domain, { longUrl });
|
||||
dispatch<ShortUrlEditedAction>({ shortCode, longUrl, domain, type: SHORT_URL_EDITED });
|
||||
} catch (e) {
|
||||
dispatch({ type: EDIT_SHORT_URL_ERROR });
|
||||
dispatch<ShortUrlEditionFailedAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { ShortUrlIdentifier, ShortUrlMeta } from '../data';
|
||||
import { GetState } from '../../container/types';
|
||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||
import { OptionalString } from '../../utils/utils';
|
||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const EDIT_SHORT_URL_META_START = 'shlink/shortUrlMeta/EDIT_SHORT_URL_META_START';
|
||||
@@ -17,12 +19,17 @@ export interface ShortUrlMetaEdition {
|
||||
meta: ShortUrlMeta;
|
||||
saving: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface ShortUrlMetaEditedAction extends Action<string>, ShortUrlIdentifier {
|
||||
meta: ShortUrlMeta;
|
||||
}
|
||||
|
||||
export interface ShortUrlMetaEditionFailedAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlMetaEdition = {
|
||||
shortCode: null,
|
||||
meta: {},
|
||||
@@ -30,9 +37,9 @@ const initialState: ShortUrlMetaEdition = {
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<ShortUrlMetaEdition, ShortUrlMetaEditedAction>({
|
||||
export default buildReducer<ShortUrlMetaEdition, ShortUrlMetaEditedAction & ShortUrlMetaEditionFailedAction>({
|
||||
[EDIT_SHORT_URL_META_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||
[EDIT_SHORT_URL_META_ERROR]: (state) => ({ ...state, saving: false, error: true }),
|
||||
[EDIT_SHORT_URL_META_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
||||
[SHORT_URL_META_EDITED]: (_, { shortCode, meta }) => ({ shortCode, meta, saving: false, error: false }),
|
||||
[RESET_EDIT_SHORT_URL_META]: () => initialState,
|
||||
}, initialState);
|
||||
@@ -49,7 +56,7 @@ export const editShortUrlMeta = (buildShlinkApiClient: ShlinkApiClientBuilder) =
|
||||
await updateShortUrlMeta(shortCode, domain, meta);
|
||||
dispatch<ShortUrlMetaEditedAction>({ shortCode, meta, domain, type: SHORT_URL_META_EDITED });
|
||||
} catch (e) {
|
||||
dispatch({ type: EDIT_SHORT_URL_META_ERROR });
|
||||
dispatch<ShortUrlMetaEditionFailedAction>({ type: EDIT_SHORT_URL_META_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||
import { GetState } from '../../container/types';
|
||||
import { OptionalString } from '../../utils/utils';
|
||||
import { ShortUrlIdentifier } from '../data';
|
||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
|
||||
@@ -17,12 +19,17 @@ export interface ShortUrlTags {
|
||||
tags: string[];
|
||||
saving: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface EditShortUrlTagsAction extends Action<string>, ShortUrlIdentifier {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface EditShortUrlTagsFailedAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlTags = {
|
||||
shortCode: null,
|
||||
tags: [],
|
||||
@@ -30,9 +37,9 @@ const initialState: ShortUrlTags = {
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<ShortUrlTags, EditShortUrlTagsAction>({
|
||||
export default buildReducer<ShortUrlTags, EditShortUrlTagsAction & EditShortUrlTagsFailedAction>({
|
||||
[EDIT_SHORT_URL_TAGS_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||
[EDIT_SHORT_URL_TAGS_ERROR]: (state) => ({ ...state, saving: false, error: true }),
|
||||
[EDIT_SHORT_URL_TAGS_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
||||
[SHORT_URL_TAGS_EDITED]: (_, { shortCode, tags }) => ({ shortCode, tags, saving: false, error: false }),
|
||||
[RESET_EDIT_SHORT_URL_TAGS]: () => initialState,
|
||||
}, initialState);
|
||||
@@ -50,7 +57,7 @@ export const editShortUrlTags = (buildShlinkApiClient: ShlinkApiClientBuilder) =
|
||||
|
||||
dispatch<EditShortUrlTagsAction>({ tags: normalizedTags, shortCode, domain, type: SHORT_URL_TAGS_EDITED });
|
||||
} catch (e) {
|
||||
dispatch({ type: EDIT_SHORT_URL_TAGS_ERROR });
|
||||
dispatch<EditShortUrlTagsFailedAction>({ type: EDIT_SHORT_URL_TAGS_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { assoc, assocPath, last, reject } from 'ramda';
|
||||
import { assoc, assocPath, init, last, pipe, reject } from 'ramda';
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { shortUrlMatches } from '../helpers';
|
||||
import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
|
||||
import { ShortUrl, ShortUrlIdentifier } from '../data';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { GetState } from '../../container/types';
|
||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||
import { ShlinkShortUrlsResponse } from '../../utils/services/types';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ShlinkShortUrlsResponse } from '../../api/types';
|
||||
import { EditShortUrlTagsAction, SHORT_URL_TAGS_EDITED } from './shortUrlTags';
|
||||
import { SHORT_URL_DELETED } from './shortUrlDeletion';
|
||||
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
|
||||
import { SHORT_URL_META_EDITED, ShortUrlMetaEditedAction } from './shortUrlMeta';
|
||||
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
|
||||
import { ShortUrlsListParams } from './shortUrlsListParams';
|
||||
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
|
||||
@@ -31,7 +32,13 @@ export interface ListShortUrlsAction extends Action<string> {
|
||||
}
|
||||
|
||||
export type ListShortUrlsCombinedAction = (
|
||||
ListShortUrlsAction & EditShortUrlTagsAction & ShortUrlEditedAction & ShortUrlMetaEditedAction & CreateVisitsAction
|
||||
ListShortUrlsAction
|
||||
& EditShortUrlTagsAction
|
||||
& ShortUrlEditedAction
|
||||
& ShortUrlMetaEditedAction
|
||||
& CreateVisitsAction
|
||||
& CreateShortUrlAction
|
||||
& DeleteShortUrlAction
|
||||
);
|
||||
|
||||
const initialState: ShortUrlsList = {
|
||||
@@ -55,10 +62,17 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
|
||||
[LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }),
|
||||
[LIST_SHORT_URLS]: (_, { shortUrls }) => ({ loading: false, error: false, shortUrls }),
|
||||
[SHORT_URL_DELETED]: (state, { shortCode, domain }) => !state.shortUrls ? state : assocPath(
|
||||
[ 'shortUrls', 'data' ],
|
||||
reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data),
|
||||
state,
|
||||
[SHORT_URL_DELETED]: pipe(
|
||||
(state: ShortUrlsList, { shortCode, domain }: DeleteShortUrlAction) => !state.shortUrls ? state : assocPath(
|
||||
[ 'shortUrls', 'data' ],
|
||||
reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data),
|
||||
state,
|
||||
),
|
||||
(state) => !state.shortUrls ? state : assocPath(
|
||||
[ 'shortUrls', 'pagination', 'totalItems' ],
|
||||
state.shortUrls.pagination.totalItems - 1,
|
||||
state,
|
||||
),
|
||||
),
|
||||
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl<EditShortUrlTagsAction>('tags'),
|
||||
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl<ShortUrlMetaEditedAction>('meta'),
|
||||
@@ -77,6 +91,20 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
|
||||
),
|
||||
state,
|
||||
),
|
||||
[CREATE_SHORT_URL]: pipe(
|
||||
// The only place where the list and the creation form coexist is the overview page.
|
||||
// There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL and remove the last one.
|
||||
(state: ShortUrlsList, { result }: CreateShortUrlAction) => !state.shortUrls ? state : assocPath(
|
||||
[ 'shortUrls', 'data' ],
|
||||
[ result, ...init(state.shortUrls.data) ],
|
||||
state,
|
||||
),
|
||||
(state: ShortUrlsList) => !state.shortUrls ? state : assocPath(
|
||||
[ 'shortUrls', 'pagination', 'totalItems' ],
|
||||
state.shortUrls.pagination.totalItems + 1,
|
||||
state,
|
||||
),
|
||||
),
|
||||
}, initialState);
|
||||
|
||||
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||
|
||||
@@ -15,6 +15,7 @@ export type OrderableFields = keyof typeof SORTABLE_FIELDS;
|
||||
|
||||
export interface ShortUrlsListParams {
|
||||
page?: string;
|
||||
itemsPerPage?: number;
|
||||
tags?: string[];
|
||||
searchTerm?: string;
|
||||
startDate?: string;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { connect as reduxConnect } from 'react-redux';
|
||||
import { assoc } from 'ramda';
|
||||
import Bottle from 'bottlejs';
|
||||
import ShortUrls from '../ShortUrls';
|
||||
import SearchBar from '../SearchBar';
|
||||
@@ -19,27 +17,22 @@ import { editShortUrlTags, resetShortUrlsTags } from '../reducers/shortUrlTags';
|
||||
import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta';
|
||||
import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
|
||||
import { editShortUrl } from '../reducers/shortUrlEdition';
|
||||
import { ConnectDecorator, ShlinkState } from '../../container/types';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { ShortUrlsTable } from '../ShortUrlsTable';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory('ShortUrls', ShortUrls, 'SearchBar', 'ShortUrlsList');
|
||||
bottle.decorator('ShortUrls', reduxConnect(
|
||||
(state: ShlinkState) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList),
|
||||
));
|
||||
bottle.decorator('ShortUrls', connect([ 'shortUrlsList' ]));
|
||||
|
||||
// Services
|
||||
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator', 'ForServerVersion');
|
||||
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
|
||||
|
||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
|
||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable');
|
||||
bottle.decorator('ShortUrlsList', connect(
|
||||
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ],
|
||||
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ],
|
||||
));
|
||||
|
||||
bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow');
|
||||
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout');
|
||||
|
||||
bottle.serviceFactory(
|
||||
'ShortUrlsRowMenu',
|
||||
ShortUrlsRowMenu,
|
||||
@@ -51,7 +44,14 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
);
|
||||
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout');
|
||||
|
||||
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult', 'ForServerVersion');
|
||||
bottle.serviceFactory(
|
||||
'CreateShortUrl',
|
||||
CreateShortUrl,
|
||||
'TagsSelector',
|
||||
'CreateShortUrlResult',
|
||||
'ForServerVersion',
|
||||
'DomainSelector',
|
||||
);
|
||||
bottle.decorator(
|
||||
'CreateShortUrl',
|
||||
connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ]),
|
||||
@@ -69,6 +69,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
bottle.serviceFactory('EditShortUrlModal', () => EditShortUrlModal);
|
||||
bottle.decorator('EditShortUrlModal', connect([ 'shortUrlEdition' ], [ 'editShortUrl' ]));
|
||||
|
||||
// Services
|
||||
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
|
||||
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.tag-card__header.tag-card__header {
|
||||
background-color: #eeeeee;
|
||||
}
|
||||
|
||||
.tag-card__header.tag-card__header,
|
||||
.tag-card__body.tag-card__body {
|
||||
padding: .75rem;
|
||||
|
||||
@@ -4,6 +4,8 @@ import Message from '../utils/Message';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { Result } from '../utils/Result';
|
||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||
import { TagsList as TagsListState } from './reducers/tagsList';
|
||||
import { TagCardProps } from './TagCard';
|
||||
|
||||
@@ -28,14 +30,14 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
||||
|
||||
const renderContent = () => {
|
||||
if (tagsList.loading) {
|
||||
return <Message noMargin loading />;
|
||||
return <Message loading />;
|
||||
}
|
||||
|
||||
if (tagsList.error) {
|
||||
return (
|
||||
<div className="col-12">
|
||||
<div className="bg-danger p-2 text-white text-center">Error loading tags :(</div>
|
||||
</div>
|
||||
<Result type="error">
|
||||
<ShlinkApiError errorData={tagsList.errorData} fallbackMessage="Error loading tags :(" />
|
||||
</Result>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +50,7 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="row">
|
||||
{tagsGroups.map((group, index) => (
|
||||
<div key={index} className="col-md-6 col-xl-3">
|
||||
{group.map((tag) => (
|
||||
@@ -63,16 +65,14 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!tagsList.loading && <SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />}
|
||||
<div className="row">
|
||||
{renderContent()}
|
||||
</div>
|
||||
{renderContent()}
|
||||
</>
|
||||
);
|
||||
}, () => 'https://shlink.io/new-visit');
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { TagDeletion } from '../reducers/tagDelete';
|
||||
import { TagModalProps } from '../data';
|
||||
import { Result } from '../../utils/Result';
|
||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||
|
||||
interface DeleteTagConfirmModalProps extends TagModalProps {
|
||||
deleteTag: (tag: string) => Promise<void>;
|
||||
@@ -11,6 +13,7 @@ interface DeleteTagConfirmModalProps extends TagModalProps {
|
||||
const DeleteTagConfirmModal = (
|
||||
{ tag, toggle, isOpen, deleteTag, tagDelete, tagDeleted }: DeleteTagConfirmModalProps,
|
||||
) => {
|
||||
const { deleting, error, errorData } = tagDelete;
|
||||
const doDelete = async () => {
|
||||
await deleteTag(tag);
|
||||
tagDeleted(tag);
|
||||
@@ -24,16 +27,16 @@ const DeleteTagConfirmModal = (
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
Are you sure you want to delete tag <b>{tag}</b>?
|
||||
{tagDelete.error && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while deleting the tag :(
|
||||
</div>
|
||||
{error && (
|
||||
<Result type="error" small className="mt-2">
|
||||
<ShlinkApiError errorData={errorData} fallbackMessage="Something went wrong while deleting the tag :(" />
|
||||
</Result>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||
<button className="btn btn-danger" disabled={tagDelete.deleting} onClick={doDelete}>
|
||||
{tagDelete.deleting ? 'Deleting tag...' : 'Delete tag'}
|
||||
<button className="btn btn-danger" disabled={deleting} onClick={doDelete}>
|
||||
{deleting ? 'Deleting tag...' : 'Delete tag'}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { handleEventPreventingDefault } from '../../utils/utils';
|
||||
import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||
import { TagModalProps } from '../data';
|
||||
import { TagEdition } from '../reducers/tagEdit';
|
||||
import { Result } from '../../utils/Result';
|
||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||
import './EditTagModal.scss';
|
||||
|
||||
interface EditTagModalProps extends TagModalProps {
|
||||
@@ -22,6 +24,7 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
||||
const [ newTagName, setNewTagName ] = useState(tag);
|
||||
const [ color, setColor ] = useState(getColorForKey(tag));
|
||||
const [ showColorPicker, toggleColorPicker, , hideColorPicker ] = useToggle();
|
||||
const { editing, error, errorData } = tagEdit;
|
||||
const saveTag = handleEventPreventingDefault(async () => editTag(tag, newTagName, color)
|
||||
.then(() => tagEdited(tag, newTagName, color))
|
||||
.then(toggle)
|
||||
@@ -54,17 +57,15 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tagEdit.error && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while editing the tag :(
|
||||
</div>
|
||||
{error && (
|
||||
<Result type="error" small className="mt-2">
|
||||
<ShlinkApiError errorData={errorData} fallbackMessage="Something went wrong while editing the tag :(" />
|
||||
</Result>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={tagEdit.editing}>
|
||||
{tagEdit.editing ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={editing}>{editing ? 'Saving...' : 'Save'}</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -17,6 +17,8 @@ interface TagsSelectorConnectProps extends TagsSelectorProps {
|
||||
tagsList: TagsList;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const TagsSelector = (colorGenerator: ColorGenerator) => (
|
||||
{ tags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }: TagsSelectorConnectProps,
|
||||
) => {
|
||||
@@ -55,8 +57,8 @@ const TagsSelector = (colorGenerator: ColorGenerator) => (
|
||||
{suggestion}
|
||||
</>
|
||||
)}
|
||||
onSuggestionsFetchRequested={() => {}}
|
||||
onSuggestionsClearRequested={() => {}}
|
||||
onSuggestionsFetchRequested={noop}
|
||||
onSuggestionsClearRequested={noop}
|
||||
onSuggestionSelected={(_, { suggestion }: SuggestionSelectedEventData<string>) => {
|
||||
addTag(suggestion);
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { GetState } from '../../container/types';
|
||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
||||
@@ -13,20 +15,25 @@ export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
|
||||
export interface TagDeletion {
|
||||
deleting: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface DeleteTagAction extends Action<string> {
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export interface DeleteTagFailedAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: TagDeletion = {
|
||||
deleting: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer({
|
||||
export default buildReducer<TagDeletion, DeleteTagFailedAction>({
|
||||
[DELETE_TAG_START]: () => ({ deleting: true, error: false }),
|
||||
[DELETE_TAG_ERROR]: () => ({ deleting: false, error: true }),
|
||||
[DELETE_TAG_ERROR]: (_, { errorData }) => ({ deleting: false, error: true, errorData }),
|
||||
[DELETE_TAG]: () => ({ deleting: false, error: false }),
|
||||
}, initialState);
|
||||
|
||||
@@ -41,7 +48,7 @@ export const deleteTag = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag:
|
||||
await deleteTags([ tag ]);
|
||||
dispatch({ type: DELETE_TAG });
|
||||
} catch (e) {
|
||||
dispatch({ type: DELETE_TAG_ERROR });
|
||||
dispatch<DeleteTagFailedAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { Action, Dispatch } from 'redux';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { GetState } from '../../container/types';
|
||||
import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
||||
@@ -18,6 +20,7 @@ export interface TagEdition {
|
||||
newName: string;
|
||||
editing: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface EditTagAction extends Action<string> {
|
||||
@@ -26,6 +29,10 @@ export interface EditTagAction extends Action<string> {
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface EditTagFailedAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: TagEdition = {
|
||||
oldName: '',
|
||||
newName: '',
|
||||
@@ -33,9 +40,9 @@ const initialState: TagEdition = {
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<TagEdition, EditTagAction>({
|
||||
export default buildReducer<TagEdition, EditTagAction & EditTagFailedAction>({
|
||||
[EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }),
|
||||
[EDIT_TAG_ERROR]: (state) => ({ ...state, editing: false, error: true }),
|
||||
[EDIT_TAG_ERROR]: (state, { errorData }) => ({ ...state, editing: false, error: true, errorData }),
|
||||
[EDIT_TAG]: (_, action) => ({
|
||||
...pick([ 'oldName', 'newName' ], action),
|
||||
editing: false,
|
||||
@@ -56,7 +63,7 @@ export const editTag = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGener
|
||||
colorGenerator.setColorForKey(newName, color);
|
||||
dispatch({ type: EDIT_TAG, oldName, newName });
|
||||
} catch (e) {
|
||||
dispatch({ type: EDIT_TAG_ERROR });
|
||||
dispatch<EditTagFailedAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user