mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-26 11:46:39 +00:00
Compare commits
166 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
785806b7a1 | ||
|
|
15b7fd5c93 | ||
|
|
9b32bd2817 | ||
|
|
8b5b035568 | ||
|
|
f7cc90bb77 | ||
|
|
7b0cda7191 | ||
|
|
9791486341 | ||
|
|
40ef51a348 | ||
|
|
a90287ed02 | ||
|
|
12f6a132bd | ||
|
|
1da7119c5c | ||
|
|
01f6f11ee2 | ||
|
|
57d4db5daa | ||
|
|
c7559e78a2 | ||
|
|
2f76c5381f | ||
|
|
304a7431ad | ||
|
|
691dabcfbc | ||
|
|
2dd35dcd44 | ||
|
|
44930b8c5f | ||
|
|
310913b222 | ||
|
|
b877aa8e5b | ||
|
|
27e3d65143 | ||
|
|
b462169e1e | ||
|
|
dc2f30c73b | ||
|
|
8df1ba4671 | ||
|
|
56a3dbd07f | ||
|
|
856ee6d65c | ||
|
|
9518a5e442 | ||
|
|
3a8c7a7bf4 | ||
|
|
7fb0658349 | ||
|
|
6d79851d18 | ||
|
|
f89e4244ea | ||
|
|
3c23016028 | ||
|
|
27c4bd792b | ||
|
|
1b158b3df4 | ||
|
|
19f0dc2920 | ||
|
|
a15917b1ae | ||
|
|
7e5397dd38 | ||
|
|
382d7b1c9f | ||
|
|
58ee123cef | ||
|
|
039a56f410 | ||
|
|
6780aa623b | ||
|
|
7752140c9d | ||
|
|
8bfd38d861 | ||
|
|
27b6676edc | ||
|
|
66c91722fc | ||
|
|
178f15b7d3 | ||
|
|
0e47f9b502 | ||
|
|
d2ad1cd54b | ||
|
|
91e003153b | ||
|
|
c6cca9c91f | ||
|
|
7330fd85ff | ||
|
|
b61d863356 | ||
|
|
f54460e8f8 | ||
|
|
036c8aafcb | ||
|
|
d55160e8f6 | ||
|
|
0572bc2854 | ||
|
|
aceb2350cf | ||
|
|
923575b38b | ||
|
|
f41a8473f8 | ||
|
|
b94cdb2680 | ||
|
|
0cdae72ebd | ||
|
|
75931edc33 | ||
|
|
d1fcd10c04 | ||
|
|
06f4cff97e | ||
|
|
0804322a9f | ||
|
|
53ba14e6f6 | ||
|
|
ead5f2033b | ||
|
|
74ac122787 | ||
|
|
13785c7beb | ||
|
|
9887cae4fd | ||
|
|
410d372755 | ||
|
|
e7a969a78d | ||
|
|
b1d6f58619 | ||
|
|
f49b74229c | ||
|
|
d88f822125 | ||
|
|
dce1cefd49 | ||
|
|
8e71b2e2b1 | ||
|
|
69cb3bd619 | ||
|
|
bf29158a8a | ||
|
|
a28a4846bc | ||
|
|
5eee86003d | ||
|
|
37a3a2022b | ||
|
|
c6be8bd96f | ||
|
|
5166340779 | ||
|
|
520e52595f | ||
|
|
461c0e0bc9 | ||
|
|
0ecb771b23 | ||
|
|
c89e2b5d25 | ||
|
|
aa8f2a0cbc | ||
|
|
eb90aa2274 | ||
|
|
2b5420a429 | ||
|
|
3484e74559 | ||
|
|
edd536cc1e | ||
|
|
322396a366 | ||
|
|
9f02bc6496 | ||
|
|
590393dcfd | ||
|
|
8029823271 | ||
|
|
4417a17d5c | ||
|
|
b8a7dccf92 | ||
|
|
cbe5f98aa3 | ||
|
|
6c2f5b99ac | ||
|
|
fa64c950ca | ||
|
|
0e4667e59c | ||
|
|
56d9dcf562 | ||
|
|
d5e8f81076 | ||
|
|
69905c4b38 | ||
|
|
08694d7693 | ||
|
|
8045fa8886 | ||
|
|
0789494a40 | ||
|
|
34837f2917 | ||
|
|
9e8c743d53 | ||
|
|
239cc4ab84 | ||
|
|
b3e79f4219 | ||
|
|
7c11a6d1ab | ||
|
|
635ee6c5eb | ||
|
|
f79bd39de7 | ||
|
|
5c6979122d | ||
|
|
402efac12e | ||
|
|
770ba624c2 | ||
|
|
d4236b914d | ||
|
|
2cc92b5b41 | ||
|
|
f0598ba47f | ||
|
|
66c5c7ebf1 | ||
|
|
741bc21a55 | ||
|
|
fb1ced5e3f | ||
|
|
3999d14bab | ||
|
|
99c77622cd | ||
|
|
bc5c25deb0 | ||
|
|
0275908f69 | ||
|
|
4be1a295d8 | ||
|
|
ee65c0c050 | ||
|
|
d718329b52 | ||
|
|
55716a8f7f | ||
|
|
5ef719c592 | ||
|
|
3a57416525 | ||
|
|
5bd57e71fd | ||
|
|
c4ed838510 | ||
|
|
affe2309b0 | ||
|
|
638ce89780 | ||
|
|
a0ab9533cb | ||
|
|
7b80948eea | ||
|
|
1cf96c7212 | ||
|
|
151175dc70 | ||
|
|
a30376344e | ||
|
|
db0c43dcdd | ||
|
|
a3550f8e52 | ||
|
|
3a3babadeb | ||
|
|
e22ad2c822 | ||
|
|
342dda3ec9 | ||
|
|
b7af07c043 | ||
|
|
6b338275d3 | ||
|
|
a72d3b2720 | ||
|
|
18042dba6e | ||
|
|
93329c5a12 | ||
|
|
5a91b668dc | ||
|
|
66aac4771c | ||
|
|
ce04b8eb58 | ||
|
|
e0c20c704e | ||
|
|
d5fadc56af | ||
|
|
bbc3342c00 | ||
|
|
76ebbd318a | ||
|
|
24801b068b | ||
|
|
4c21ad0a89 | ||
|
|
f626f9b046 | ||
|
|
ccffa0fe12 |
@@ -14,5 +14,8 @@
|
||||
"process": true,
|
||||
"setImmediate": true
|
||||
},
|
||||
"ignorePatterns": ["src/service*.ts"]
|
||||
"ignorePatterns": ["src/service*.ts"],
|
||||
"rules": {
|
||||
"complexity": "off"
|
||||
}
|
||||
}
|
||||
|
||||
18
.github/workflows/deploy-preview.yml
vendored
18
.github/workflows/deploy-preview.yml
vendored
@@ -17,25 +17,13 @@ jobs:
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.15
|
||||
- name: Generate slug
|
||||
id: generate_slug
|
||||
run: echo "##[set-output name=slug;]$(echo ${GITHUB_HEAD_REF#refs/heads/} | sed -r 's/[~\^]+//g' | sed -r 's/[^a-zA-Z0-9]+/-/g' | sed -r 's/^-+\|-+$//g' | tr A-Z a-z)"
|
||||
- name: Build
|
||||
run: |
|
||||
npm ci && \
|
||||
node ./scripts/set-homepage.js /shlink-web-client/${{ steps.generate_slug.outputs.slug }} && \
|
||||
node ./scripts/set-homepage.js /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
|
||||
rm src/service-worker.ts && \
|
||||
npm run build
|
||||
- name: Deploy
|
||||
uses: JamesIves/github-pages-deploy-action@4.1.1
|
||||
- name: Deploy preview
|
||||
uses: shlinkio/deploy-preview-action@v1.0.1
|
||||
with:
|
||||
branch: preview-env
|
||||
folder: build
|
||||
target-folder: ${{ steps.generate_slug.outputs.slug }}
|
||||
- name: Publish env
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
header: Preview environment
|
||||
message: |
|
||||
## Preview environment
|
||||
https://shlinkio.github.io/shlink-web-client/${{ steps.generate_slug.outputs.slug }}/
|
||||
|
||||
2
.github/workflows/docker-image-build.yml
vendored
2
.github/workflows/docker-image-build.yml
vendored
@@ -3,7 +3,7 @@ name: Build docker image
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
|
||||
1
.github/workflows/publish-release.yml
vendored
1
.github/workflows/publish-release.yml
vendored
@@ -21,7 +21,6 @@ jobs:
|
||||
uses: docker://antonyurchenko/git-release:latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ALLOW_TAG_PREFIX: "true"
|
||||
ALLOW_EMPTY_CHANGELOG: "true"
|
||||
with:
|
||||
args: |
|
||||
|
||||
82
CHANGELOG.md
82
CHANGELOG.md
@@ -4,6 +4,88 @@ 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.3.0] - 2021-09-25
|
||||
### Added
|
||||
* [#465](https://github.com/shlinkio/shlink-web-client/issues/465) Added new page to manage domains and their redirects, when consuming Shlink 2.8 or higher.
|
||||
* [#460](https://github.com/shlinkio/shlink-web-client/issues/460) Added dynamic title on hover for tags with a very long title.
|
||||
* [#462](https://github.com/shlinkio/shlink-web-client/issues/462) Now it is possible to paste multiple comma-separated tags in the tags selector, making all of them to be added as individual tags.
|
||||
* [#463](https://github.com/shlinkio/shlink-web-client/issues/463) The strategy to determine which tags to suggest in the TagsSelector during short URL creation, can now be configured:
|
||||
|
||||
* `startsWith`: Suggests tags that start with the input. This is the default behavior for keep it as it was so far.
|
||||
* `includes`: Suggests tags that contain the input.
|
||||
|
||||
* [#464](https://github.com/shlinkio/shlink-web-client/issues/464) Added support to download QR codes. This feature requires an unreleased version of Shlink, so it comes disabled, and will get enabled as soon as Shlink v2.9 is released.
|
||||
* [#469](https://github.com/shlinkio/shlink-web-client/issues/469) Added support `errorCorrection` in QR codes, when consuming Shlink 2.8 or higher.
|
||||
* [#459](https://github.com/shlinkio/shlink-web-client/issues/459) Added new list mode to display tags.
|
||||
|
||||
The mode is optional, and you can toggle between the classic cards mode or the new list mode whenever you want.
|
||||
|
||||
You can also configure the default mode from settings.
|
||||
|
||||
### Changed
|
||||
* [#408](https://github.com/shlinkio/shlink-web-client/issues/408) Updated to Chart.js 3.5
|
||||
* [#486](https://github.com/shlinkio/shlink-web-client/issues/486) Refactored components used to render visits charts, making them easier to maintain and understand.
|
||||
* [#409](https://github.com/shlinkio/shlink-web-client/issues/409) Increased required code coverage and added hard threshold check.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* [#491](https://github.com/shlinkio/shlink-web-client/issues/491) Dropped support for Shlink older than v2.4.0.
|
||||
|
||||
### Fixed
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## [3.2.1] - 2021-09-12
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#478](https://github.com/shlinkio/shlink-web-client/issues/478) Fixed tags including special chars not being properly URL encoded before using them as query params.
|
||||
* [#480](https://github.com/shlinkio/shlink-web-client/issues/480) Fixed servers import on Chromium-based browsers when using windows.
|
||||
* [#482](https://github.com/shlinkio/shlink-web-client/issues/480) Fixed end date not being set to the end of the day when filtering visits using a "smart filter" (last 7 days, last 30 days, etc).
|
||||
|
||||
|
||||
## [3.2.0] - 2021-07-12
|
||||
### Added
|
||||
* [#433](https://github.com/shlinkio/shlink-web-client/pull/433) Added support to provide a default server to connect to via env vars:
|
||||
|
||||
* `SHLINK_SERVER_URL`: The URL of the Shlink server to configure by default.
|
||||
* `SHLINK_SERVER_API_KEY`: The API key of the Shlink server.
|
||||
* `SHLINK_SERVER_NAME`: A name you want to give to this server. Defaults to *Shlink* if not provided.
|
||||
|
||||
* [#432](https://github.com/shlinkio/shlink-web-client/pull/432) Added support to provide the `servers.json` file inside a `conf.d` folder.
|
||||
* [#440](https://github.com/shlinkio/shlink-web-client/issues/440) Added hint of what visits come potentially from a bot, in the visits table, when consuming Shlink >=2.7.
|
||||
* [#431](https://github.com/shlinkio/shlink-web-client/issues/431) Added support to filter out visits from potential bots in visits sections, when consuming Shlink >=2.7.
|
||||
* [#430](https://github.com/shlinkio/shlink-web-client/issues/430) Added support to set new and existing short URLs as crawlable, when consuming Shlink >=2.7.
|
||||
* [#450](https://github.com/shlinkio/shlink-web-client/issues/450) Improved landing page design.
|
||||
* [#449](https://github.com/shlinkio/shlink-web-client/issues/449) Improved PWA update banner, allowing to restart the app directly from it without having to close the tab.
|
||||
|
||||
### Changed
|
||||
* [#442](https://github.com/shlinkio/shlink-web-client/issues/442) Visits filtering now goes through the corresponding reducer.
|
||||
* [#337](https://github.com/shlinkio/shlink-web-client/issues/337) Replaced moment.js with date-fns.
|
||||
* [#360](https://github.com/shlinkio/shlink-web-client/issues/360) Changed component used to generate a tags selector, switching from `react-tagsinput`, which is no longer maintained, to `react-tag-autocomplete`.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#438](https://github.com/shlinkio/shlink-web-client/issues/438) Fixed horizontal scrolling in short URLs list on mobile devices when the long URL didn't have words to break.
|
||||
|
||||
|
||||
## [3.1.2] - 2021-06-06
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
FROM node:14.15-alpine as node
|
||||
FROM node:14.17-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.6-alpine
|
||||
FROM nginx:1.21-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
|
||||
COPY scripts/docker/servers_from_env.sh /docker-entrypoint.d/30-shlink-servers-json.sh
|
||||
COPY --from=node /shlink-web-client/build /usr/share/nginx/html
|
||||
|
||||
19
README.md
19
README.md
@@ -68,6 +68,25 @@ Those servers can be exported and imported in other browsers, but if for some re
|
||||
If you are using the shlink-web-client docker image, you can mount the `servers.json` file in a volume inside `/usr/share/nginx/html`, which is the app's document root inside the container.
|
||||
|
||||
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/servers.json:/usr/share/nginx/html/servers.json shlinkio/shlink-web-client
|
||||
|
||||
Alternatively, you can mount a `conf.d` directory, which in turn contains the `servers.json` file, in a volume inside `/usr/share/nginx/html`. *(since shlink-web-client 3.2.0)*.
|
||||
|
||||
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/my-config/:/usr/share/nginx/html/conf.d/ shlinkio/shlink-web-client
|
||||
|
||||
If you want to pre-configure a single server, you can provide its config via env vars. When the container starts up, it will build the `servers.json` file dynamically based on them. *(since shlink-web-client 3.2.0)*.
|
||||
|
||||
* `SHLINK_SERVER_URL`: The fully qualified URL for the Shlink server.
|
||||
* `SHLINK_SERVER_API_KEY`: The API key.
|
||||
* `SHLINK_SERVER_NAME`: The name to be displayed. Defaults to **Shlink** if not provided.
|
||||
|
||||
```shell
|
||||
docker run \
|
||||
--name shlink-web-client \
|
||||
-p 8000:80 \
|
||||
-e SHLINK_SERVER_URL=https://doma.in \
|
||||
-e SHLINK_SERVER_API_KEY=6aeb82c6-e275-4538-a747-31f9abfba63c \
|
||||
shlinkio/shlink-web-client
|
||||
```
|
||||
|
||||
> **Be extremely careful when using this feature.**
|
||||
>
|
||||
|
||||
@@ -20,6 +20,11 @@ server {
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# servers.json may be on the root, or in conf.d directory
|
||||
location = /servers.json {
|
||||
try_files /servers.json /conf.d/servers.json;
|
||||
}
|
||||
|
||||
# When requesting static paths with extension, try them, and return a 404 if not found
|
||||
location ~* .+\.(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
|
||||
try_files $uri $uri/ =404;
|
||||
|
||||
@@ -3,7 +3,7 @@ version: '3'
|
||||
services:
|
||||
shlink_web_client_node:
|
||||
container_name: shlink_web_client_node
|
||||
image: node:14.15-alpine
|
||||
image: node:14.17-alpine
|
||||
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
module.exports = {
|
||||
coverageDirectory: '<rootDir>/coverage',
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{js,ts,tsx}',
|
||||
'!src/registerServiceWorker.js',
|
||||
'!src/index.ts',
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/*.{ts,tsx}',
|
||||
'!src/reducers/index.ts',
|
||||
'!src/**/provideServices.ts',
|
||||
'!src/container/*.ts',
|
||||
],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
statements: 85,
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 85,
|
||||
},
|
||||
},
|
||||
resolver: 'jest-pnp-resolver',
|
||||
setupFiles: [
|
||||
'react-app-polyfill/jsdom',
|
||||
|
||||
232
package-lock.json
generated
232
package-lock.json
generated
@@ -6363,15 +6363,6 @@
|
||||
"@babel/types": "^7.3.0"
|
||||
}
|
||||
},
|
||||
"@types/chart.js": {
|
||||
"version": "2.9.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.31.tgz",
|
||||
"integrity": "sha512-hzS6phN/kx3jClk3iYqEHNnYIRSi4RZrIGJ8CDLjgatpHoftCezvC44uqB3o3OUm9ftU1m7sHG8+RLyPTlACrA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"moment": "^2.10.2"
|
||||
}
|
||||
},
|
||||
"@types/cheerio": {
|
||||
"version": "0.22.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.22.tgz",
|
||||
@@ -6517,15 +6508,6 @@
|
||||
"integrity": "sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/moment": {
|
||||
"version": "2.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/moment/-/moment-2.13.0.tgz",
|
||||
"integrity": "sha1-YE69GJvDvDShVIaJQE5hoqSqyJY=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"moment": "*"
|
||||
}
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "12.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.11.tgz",
|
||||
@@ -6587,15 +6569,6 @@
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"@types/react-autosuggest": {
|
||||
"version": "10.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-autosuggest/-/react-autosuggest-10.1.2.tgz",
|
||||
"integrity": "sha512-K23lmXhC3Bbd8y/jm5+wYrw/NAeN4U/wlHTgAEBIwLOyQKFCFYA3ONKte9P21L+RGIXRP8UlzHOSRtmIZw5Nqw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-color": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.4.tgz",
|
||||
@@ -6678,10 +6651,10 @@
|
||||
"@types/react-router": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-tagsinput": {
|
||||
"version": "3.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-tagsinput/-/react-tagsinput-3.19.7.tgz",
|
||||
"integrity": "sha512-yj/3iFBLoan/0vzXMxC9zGhO1uJ89qjQldekf0o3fX4mYdaAPW/VbP921fsyYt6PdHmJ9UMo+kERSMzUAml1xQ==",
|
||||
"@types/react-tag-autocomplete": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-tag-autocomplete/-/react-tag-autocomplete-6.1.0.tgz",
|
||||
"integrity": "sha512-6qJQS81ZMaqV/ZSADwiU91TXnR6ZJINPqoV3z2SMMSlUcO6CV8Vc5QnqcqcVTj2CHnU3UQ2Q5QfSj3NyXomcDg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
@@ -7627,9 +7600,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"arch": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/arch/-/arch-2.1.2.tgz",
|
||||
"integrity": "sha512-NTBIIbAfkJeIletyABbVtdPgeKfDafR+1mZV/AyyfC1UkVkp9iUjV+wwmqtUgphHYajbI86jejBJp5e+jkGTiQ==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz",
|
||||
"integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"arg": {
|
||||
@@ -10596,30 +10569,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"chart.js": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz",
|
||||
"integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==",
|
||||
"requires": {
|
||||
"chartjs-color": "^2.1.0",
|
||||
"moment": "^2.10.2"
|
||||
}
|
||||
},
|
||||
"chartjs-color": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz",
|
||||
"integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==",
|
||||
"requires": {
|
||||
"chartjs-color-string": "^0.6.0",
|
||||
"color-convert": "^1.9.3"
|
||||
}
|
||||
},
|
||||
"chartjs-color-string": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz",
|
||||
"integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==",
|
||||
"requires": {
|
||||
"color-name": "^1.0.0"
|
||||
}
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.5.1.tgz",
|
||||
"integrity": "sha512-m5kzt72I1WQ9LILwQC4syla/LD/N413RYv2Dx2nnTkRS9iv/ey1xLTt0DnPc/eWV4zI+BgEgDYBIzbQhZHc/PQ=="
|
||||
},
|
||||
"check-types": {
|
||||
"version": "11.1.2",
|
||||
@@ -10810,40 +10762,48 @@
|
||||
"dev": true
|
||||
},
|
||||
"clipboardy": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-1.2.3.tgz",
|
||||
"integrity": "sha512-2WNImOvCRe6r63Gk9pShfkwXsVtKCroMAevIbiae021mS850UkWPbevxsBz3tnvjZIEGvlwaqCPsw+4ulzNgJA==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-2.3.0.tgz",
|
||||
"integrity": "sha512-mKhiIL2DrQIsuXMgBgnfEHOZOryC7kY7YO//TN6c63wlEm3NG5tz+YgY5rVi29KCmq/QQjKYvM7a19+MDOTHOQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"arch": "^2.1.0",
|
||||
"execa": "^0.8.0"
|
||||
"arch": "^2.1.1",
|
||||
"execa": "^1.0.0",
|
||||
"is-wsl": "^2.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"cross-spawn": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
|
||||
"integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"lru-cache": "^4.0.1",
|
||||
"shebang-command": "^1.2.0",
|
||||
"which": "^1.2.9"
|
||||
}
|
||||
},
|
||||
"execa": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-0.8.0.tgz",
|
||||
"integrity": "sha1-2NdrvBtVIX7RkP1t1J08d07PyNo=",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz",
|
||||
"integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cross-spawn": "^5.0.1",
|
||||
"get-stream": "^3.0.0",
|
||||
"cross-spawn": "^6.0.0",
|
||||
"get-stream": "^4.0.0",
|
||||
"is-stream": "^1.1.0",
|
||||
"npm-run-path": "^2.0.0",
|
||||
"p-finally": "^1.0.0",
|
||||
"signal-exit": "^3.0.0",
|
||||
"strip-eof": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"get-stream": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
|
||||
"integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"pump": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"is-wsl": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-docker": "^2.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -10967,6 +10927,7 @@
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz",
|
||||
"integrity": "sha1-u3GFBpDh8TZWfeYp0tVHHe2kweg=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-name": "1.1.3"
|
||||
},
|
||||
@@ -10974,14 +10935,16 @@
|
||||
"color-name": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz",
|
||||
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
|
||||
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI="
|
||||
"integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI=",
|
||||
"dev": true
|
||||
},
|
||||
"color-string": {
|
||||
"version": "1.5.4",
|
||||
@@ -12082,9 +12045,9 @@
|
||||
}
|
||||
},
|
||||
"date-fns": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.16.1.tgz",
|
||||
"integrity": "sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ=="
|
||||
"version": "2.22.1",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.22.1.tgz",
|
||||
"integrity": "sha512-yUFPQjrxEmIsMqlHhAhmxkuH769baF21Kk+nZwZGyrMoyLA+LugaQtC0+Tqf9CBUUULWwUJt6Q5ySI3LJDDCGg=="
|
||||
},
|
||||
"date-format": {
|
||||
"version": "3.0.0",
|
||||
@@ -12928,7 +12891,8 @@
|
||||
"es6-promise": {
|
||||
"version": "4.2.8",
|
||||
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
|
||||
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
|
||||
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==",
|
||||
"dev": true
|
||||
},
|
||||
"escalade": {
|
||||
"version": "3.1.1",
|
||||
@@ -14269,12 +14233,6 @@
|
||||
"integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=",
|
||||
"dev": true
|
||||
},
|
||||
"fast-deep-equal": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
|
||||
"integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=",
|
||||
"dev": true
|
||||
},
|
||||
"fast-glob": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.4.tgz",
|
||||
@@ -19015,11 +18973,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"moment": {
|
||||
"version": "2.29.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
|
||||
"integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ=="
|
||||
},
|
||||
"moo": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.1.tgz",
|
||||
@@ -24584,31 +24537,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-autosuggest": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-autosuggest/-/react-autosuggest-10.1.0.tgz",
|
||||
"integrity": "sha512-/azBHmc6z/31s/lBf6irxPf/7eejQdR0IqnZUzjdSibtlS8+Rw/R79pgDAo6Ft5QqCUTyEQ+f0FhL+1olDQ8OA==",
|
||||
"requires": {
|
||||
"es6-promise": "^4.2.8",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-themeable": "^1.1.0",
|
||||
"section-iterator": "^2.0.0",
|
||||
"shallow-equal": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"react-chartjs-2": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-2.11.1.tgz",
|
||||
"integrity": "sha512-G7cNq/n2Bkh/v4vcI+GKx7Q1xwZexKYhOSj2HmrFXlvNeaURWXun6KlOUpEQwi1cv9Tgs4H3kGywDWMrX2kxfA==",
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-3.0.4.tgz",
|
||||
"integrity": "sha512-pcbFNpkPMTkGXXJ7k7hnukbRD0ZV01qB6JQY1ontITc2IYvhGlK6BBDy28VeydYs1Dl/c5ZpRgRVEtT5GUnxcQ==",
|
||||
"requires": {
|
||||
"lodash": "^4.17.19",
|
||||
"prop-types": "^15.7.2"
|
||||
"lodash": "^4.17.19"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": {
|
||||
"version": "4.17.20",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
|
||||
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -24903,11 +24843,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
|
||||
},
|
||||
"react-moment": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-moment/-/react-moment-1.0.0.tgz",
|
||||
"integrity": "sha512-J4iIiwUT4oZcL7cp2U7naQKbQtqvmzGXXBMg/DLj+Pi7n9EW0VhBRx/1aJ1Tp2poCqTCAPoadLEoUIkReGnNNg=="
|
||||
},
|
||||
"react-onclickoutside": {
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.10.0.tgz",
|
||||
@@ -25012,25 +24947,10 @@
|
||||
"resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-6.0.1.tgz",
|
||||
"integrity": "sha512-69nonicgjT4ofeHxZSpjuz37BoIiWMEbUYkX0mdTCY2mX1U53XDzDUIOVKRg6vVBNGL+pxYjbRzmylXWORh1xQ=="
|
||||
},
|
||||
"react-tagsinput": {
|
||||
"version": "3.19.0",
|
||||
"resolved": "https://registry.yarnpkg.com/react-tagsinput/-/react-tagsinput-3.19.0.tgz",
|
||||
"integrity": "sha1-bjtFWV8tKV1GV78ZRJGYj5SMqr8="
|
||||
},
|
||||
"react-themeable": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-themeable/-/react-themeable-1.1.0.tgz",
|
||||
"integrity": "sha1-fURm3ZsrX6dQWHJ4JenxUro3mg4=",
|
||||
"requires": {
|
||||
"object-assign": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"object-assign": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz",
|
||||
"integrity": "sha1-m+3VygiXlJvKR+f/QIBi1Un1h/I="
|
||||
}
|
||||
}
|
||||
"react-tag-autocomplete": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-6.1.0.tgz",
|
||||
"integrity": "sha512-AMhVqxEEIrOfzH0A9XrpsTaLZCVYgjjxp3DSTuSvx91LBSFI6uYcKe38ltR/H/TQw4aytofVghQ1hR9sKpXRQA=="
|
||||
},
|
||||
"react-transition-group": {
|
||||
"version": "2.9.0",
|
||||
@@ -26135,11 +26055,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"section-iterator": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-iterator/-/section-iterator-2.0.0.tgz",
|
||||
"integrity": "sha1-v0RNev7rlK1Dw5rS+yYVFifMuio="
|
||||
},
|
||||
"select-hose": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
|
||||
@@ -26215,34 +26130,22 @@
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"version": "11.3.2",
|
||||
"resolved": "https://registry.npmjs.org/serve/-/serve-11.3.2.tgz",
|
||||
"integrity": "sha512-yKWQfI3xbj/f7X1lTBg91fXBP0FqjJ4TEi+ilES5yzH0iKJpN5LjNb1YzIfQg9Rqn4ECUS2SOf2+Kmepogoa5w==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/serve/-/serve-12.0.0.tgz",
|
||||
"integrity": "sha512-BkTsETQYynAZ7rXX414kg4X6EvuZQS3UVs1NY0VQYdRHSTYWPYcH38nnDh48D0x6ONuislgjag8uKlU2gTBImA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@zeit/schemas": "2.6.0",
|
||||
"ajv": "6.5.3",
|
||||
"ajv": "6.12.6",
|
||||
"arg": "2.0.0",
|
||||
"boxen": "1.3.0",
|
||||
"chalk": "2.4.1",
|
||||
"clipboardy": "1.2.3",
|
||||
"clipboardy": "2.3.0",
|
||||
"compression": "1.7.3",
|
||||
"serve-handler": "6.1.3",
|
||||
"update-check": "1.5.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.5.3.tgz",
|
||||
"integrity": "sha512-LqZ9wY+fx3UMiiPd741yB2pj3hhil+hQc8taf4o2QGRFpWgZ2V5C8HA165DY9sS3fJwsk7uT7ZlFEyC3Ig3lLg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fast-deep-equal": "^2.0.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
}
|
||||
},
|
||||
"chalk": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz",
|
||||
@@ -26388,11 +26291,6 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"shallow-equal": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
|
||||
"integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA=="
|
||||
},
|
||||
"shebang-command": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
|
||||
|
||||
27
package.json
27
package.json
@@ -7,16 +7,18 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "npm run lint:css && npm run lint:js",
|
||||
"lint:js": "eslint --ext .js,.ts,.tsx src test",
|
||||
"lint:js:fix": "npm run lint:js -- --fix",
|
||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||
"lint:js": "eslint --ext .js,.ts,.tsx src test",
|
||||
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
||||
"lint:css:fix": "npm run lint:css -- --fix",
|
||||
"lint:js:fix": "npm run lint:js -- --fix",
|
||||
"start": "node scripts/start.js",
|
||||
"serve:build": "serve ./build",
|
||||
"build": "node scripts/build.js",
|
||||
"test": "node scripts/test.js --env=jsdom --colors --verbose",
|
||||
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
||||
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
|
||||
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
|
||||
"test:ci": "npm run test:coverage -- --coverageReporters=clover",
|
||||
"test:pretty": "npm run test:coverage -- --coverageReporters=html",
|
||||
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -29,30 +31,28 @@
|
||||
"bootstrap": "^4.6.0",
|
||||
"bottlejs": "^2.0.0",
|
||||
"bowser": "^2.11.0",
|
||||
"chart.js": "^2.9.4",
|
||||
"chart.js": "^3.5.1",
|
||||
"classnames": "^2.2.6",
|
||||
"compare-versions": "^3.6.0",
|
||||
"csvjson": "^5.1.0",
|
||||
"date-fns": "^2.22.1",
|
||||
"event-source-polyfill": "^1.0.22",
|
||||
"leaflet": "^1.7.1",
|
||||
"moment": "^2.29.1",
|
||||
"promise": "^8.1.0",
|
||||
"qs": "^6.9.6",
|
||||
"ramda": "^0.27.1",
|
||||
"react": "^17.0.1",
|
||||
"react-autosuggest": "^10.1.0",
|
||||
"react-chartjs-2": "^2.11.1",
|
||||
"react-chartjs-2": "^3.0.4",
|
||||
"react-color": "^2.19.3",
|
||||
"react-copy-to-clipboard": "^5.0.2",
|
||||
"react-datepicker": "^3.6.0",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-external-link": "^1.2.0",
|
||||
"react-leaflet": "^3.1.0",
|
||||
"react-moment": "^1.0.0",
|
||||
"react-redux": "^7.2.2",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-swipeable": "^6.0.1",
|
||||
"react-tagsinput": "^3.19.0",
|
||||
"react-tag-autocomplete": "^6.1.0",
|
||||
"reactstrap": "^8.9.0",
|
||||
"redux": "^4.0.5",
|
||||
"redux-localstorage-simple": "^2.4.0",
|
||||
@@ -73,16 +73,13 @@
|
||||
"@stryker-mutator/jest-runner": "^5.0.0",
|
||||
"@stryker-mutator/typescript-checker": "^5.0.0",
|
||||
"@svgr/webpack": "^5.5.0",
|
||||
"@types/chart.js": "^2.9.31",
|
||||
"@types/classnames": "^2.2.11",
|
||||
"@types/enzyme": "^3.10.8",
|
||||
"@types/jest": "^26.0.20",
|
||||
"@types/leaflet": "^1.5.23",
|
||||
"@types/moment": "^2.13.0",
|
||||
"@types/qs": "^6.9.5",
|
||||
"@types/ramda": "^0.27.38",
|
||||
"@types/react": "^17.0.2",
|
||||
"@types/react-autosuggest": "^10.1.2",
|
||||
"@types/react-color": "^3.0.4",
|
||||
"@types/react-copy-to-clipboard": "^5.0.0",
|
||||
"@types/react-datepicker": "^3.1.5",
|
||||
@@ -90,7 +87,7 @@
|
||||
"@types/react-leaflet": "^2.5.2",
|
||||
"@types/react-redux": "^7.1.16",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"@types/react-tagsinput": "^3.19.7",
|
||||
"@types/react-tag-autocomplete": "^6.1.0",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.3.1",
|
||||
"adm-zip": "^0.4.16",
|
||||
@@ -134,7 +131,7 @@
|
||||
"resolve": "^1.19.0",
|
||||
"sass": "^1.29.0",
|
||||
"sass-loader": "^10.1.0",
|
||||
"serve": "^11.3.2",
|
||||
"serve": "^12.0.0",
|
||||
"stryker-cli": "^1.0.0",
|
||||
"style-loader": "^2.0.0",
|
||||
"stylelint": "^13.7.2",
|
||||
|
||||
@@ -5,12 +5,12 @@ set -ex
|
||||
PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
|
||||
DOCKER_IMAGE="shlinkio/shlink-web-client"
|
||||
|
||||
if [[ "$GITHUB_REF" == *"main"* ]]; then
|
||||
if [[ "$GITHUB_REF" == *"develop"* ]]; then
|
||||
docker buildx build --push \
|
||||
--platform ${PLATFORMS} \
|
||||
-t ${DOCKER_IMAGE}:latest .
|
||||
|
||||
# If ref is not main, then this is a tag. Build that docker tag and also "stable"
|
||||
# If ref is not develop, then this is a tag. Build that docker tag and also "stable"
|
||||
else
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
TAGS="-t ${DOCKER_IMAGE}:${VERSION}"
|
||||
|
||||
16
scripts/docker/servers_from_env.sh
Executable file
16
scripts/docker/servers_from_env.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
ME=$(basename $0)
|
||||
|
||||
setup_single_shlink_server() {
|
||||
[ -n "$SHLINK_SERVER_URL" ] || return 0
|
||||
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0
|
||||
local name="${SHLINK_SERVER_NAME:-Shlink}"
|
||||
echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\"}]" > /usr/share/nginx/html/servers.json
|
||||
}
|
||||
|
||||
setup_single_shlink_server
|
||||
|
||||
exit 0
|
||||
42
src/App.scss
42
src/App.scss
@@ -1,42 +0,0 @@
|
||||
@import './utils/base';
|
||||
@import './utils/mixins/horizontal-align';
|
||||
|
||||
.app-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app {
|
||||
padding-top: $headerHeight;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.shlink-wrapper {
|
||||
min-height: 100%;
|
||||
padding-bottom: $footer-height + $footer-margin;
|
||||
margin-bottom: -($footer-height + $footer-margin);
|
||||
}
|
||||
|
||||
.shlink-footer {
|
||||
height: $footer-height;
|
||||
margin-top: $footer-margin;
|
||||
padding: 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 0 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.app__update-banner.app__update-banner {
|
||||
@include horizontal-align();
|
||||
|
||||
position: fixed;
|
||||
top: $headerHeight - 25px;
|
||||
padding: 0 4rem 0 0;
|
||||
z-index: 1040;
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
text-align: center;
|
||||
width: 700px;
|
||||
max-width: calc(100% - 30px);
|
||||
box-shadow: 0 0 1rem var(--brand-color);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import qs from 'qs';
|
||||
import { isEmpty, isNil, reject } from 'ramda';
|
||||
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
||||
import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||
@@ -16,7 +15,10 @@ import {
|
||||
ShlinkDomain,
|
||||
ShlinkDomainsResponse,
|
||||
ShlinkVisitsOverview,
|
||||
ShlinkEditDomainRedirects,
|
||||
ShlinkDomainRedirects,
|
||||
} from '../types';
|
||||
import { stringifyQuery } from '../../utils/helpers/query';
|
||||
|
||||
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
||||
const rejectNilProps = reject(isNil);
|
||||
@@ -108,6 +110,11 @@ export default class ShlinkApiClient {
|
||||
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
|
||||
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data);
|
||||
|
||||
public readonly editDomainRedirects = async (
|
||||
domainRedirects: ShlinkEditDomainRedirects,
|
||||
): Promise<ShlinkDomainRedirects> =>
|
||||
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data);
|
||||
|
||||
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
|
||||
try {
|
||||
return await this.axios({
|
||||
@@ -116,7 +123,7 @@ export default class ShlinkApiClient {
|
||||
headers: { 'X-Api-Key': this.apiKey },
|
||||
params: rejectNilProps(query),
|
||||
data: body,
|
||||
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
|
||||
paramsSerializer: stringifyQuery,
|
||||
});
|
||||
} catch (e) {
|
||||
const { response } = e;
|
||||
|
||||
6
src/api/types/actions.ts
Normal file
6
src/api/types/actions.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Action } from 'redux';
|
||||
import { ProblemDetailsError } from './index';
|
||||
|
||||
export interface ApiErrorAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export interface ShlinkVisitsParams {
|
||||
itemsPerPage?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
excludeBots?: boolean;
|
||||
}
|
||||
|
||||
export interface ShlinkShortUrlData extends ShortUrlMeta {
|
||||
@@ -64,9 +65,20 @@ export interface ShlinkShortUrlData extends ShortUrlMeta {
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface ShlinkDomainRedirects {
|
||||
baseUrlRedirect: string | null;
|
||||
regular404Redirect: string | null;
|
||||
invalidShortUrlRedirect: string | null;
|
||||
}
|
||||
|
||||
export interface ShlinkEditDomainRedirects extends Partial<ShlinkDomainRedirects> {
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export interface ShlinkDomain {
|
||||
domain: string;
|
||||
isDefault: boolean;
|
||||
redirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.8
|
||||
}
|
||||
|
||||
export interface ShlinkDomainsResponse {
|
||||
|
||||
26
src/app/App.scss
Normal file
26
src/app/App.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.app-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app {
|
||||
padding-top: $headerHeight;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.shlink-wrapper {
|
||||
min-height: 100%;
|
||||
padding-bottom: $footer-height + $footer-margin;
|
||||
margin-bottom: -($footer-height + $footer-margin);
|
||||
}
|
||||
|
||||
.shlink-footer {
|
||||
height: $footer-height;
|
||||
margin-top: $footer-margin;
|
||||
padding: 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 0 15px;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, FC } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import { Alert } from 'reactstrap';
|
||||
import NotFound from './common/NotFound';
|
||||
import { ServersMap } from './servers/data';
|
||||
import { Settings } from './settings/reducers/settings';
|
||||
import { changeThemeInMarkup } from './utils/theme';
|
||||
import { SimpleCard } from './utils/SimpleCard';
|
||||
import NotFound from '../common/NotFound';
|
||||
import { ServersMap } from '../servers/data';
|
||||
import { Settings } from '../settings/reducers/settings';
|
||||
import { changeThemeInMarkup } from '../utils/theme';
|
||||
import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
||||
import { forceUpdate } from '../utils/helpers/sw';
|
||||
import './App.scss';
|
||||
|
||||
interface AppProps {
|
||||
@@ -55,16 +55,7 @@ const App = (
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
className="app__update-banner"
|
||||
tag={SimpleCard}
|
||||
color="secondary"
|
||||
isOpen={appUpdated}
|
||||
toggle={resetAppUpdate}
|
||||
>
|
||||
<h4 className="mb-4">This app has just been updated!</h4>
|
||||
<p className="mb-0">Restart it to enjoy the new features.</p>
|
||||
</Alert>
|
||||
<AppUpdateBanner isOpen={appUpdated} toggle={resetAppUpdate} forceUpdate={forceUpdate} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import Bottle from 'bottlejs';
|
||||
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
|
||||
import App from '../../App';
|
||||
import App from '../App';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
|
||||
17
src/common/AppUpdateBanner.scss
Normal file
17
src/common/AppUpdateBanner.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
@import '../utils/base';
|
||||
@import '../utils/mixins/horizontal-align';
|
||||
|
||||
.app-update-banner.app-update-banner {
|
||||
@include horizontal-align();
|
||||
|
||||
position: fixed;
|
||||
top: $headerHeight - 25px;
|
||||
padding: 0 4rem 0 0;
|
||||
z-index: 1040;
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
text-align: center;
|
||||
width: 700px;
|
||||
max-width: calc(100% - 30px);
|
||||
box-shadow: 0 0 1rem var(--brand-color);
|
||||
}
|
||||
34
src/common/AppUpdateBanner.tsx
Normal file
34
src/common/AppUpdateBanner.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { FC, MouseEventHandler } from 'react';
|
||||
import { Alert, Button } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import './AppUpdateBanner.scss';
|
||||
|
||||
interface AppUpdateBannerProps {
|
||||
isOpen: boolean;
|
||||
toggle: MouseEventHandler<any>;
|
||||
forceUpdate: Function;
|
||||
}
|
||||
|
||||
export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forceUpdate }) => {
|
||||
const [ isUpdating,, setUpdating ] = useToggle();
|
||||
const update = () => {
|
||||
setUpdating();
|
||||
forceUpdate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert className="app-update-banner" isOpen={isOpen} toggle={toggle} tag={SimpleCard} color="secondary">
|
||||
<h4 className="mb-4">This app has just been updated!</h4>
|
||||
<p className="mb-0">
|
||||
Restart it to enjoy the new features.
|
||||
<Button disabled={isUpdating} className="ml-2" color="secondary" size="sm" onClick={update}>
|
||||
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ml-1" /></>}
|
||||
{isUpdating && <>Restarting...</>}
|
||||
</Button>
|
||||
</p>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
faTags as tagsIcon,
|
||||
faPen as editIcon,
|
||||
faHome as overviewIcon,
|
||||
faGlobe as domainsIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { FC } from 'react';
|
||||
@@ -11,11 +12,12 @@ import { NavLink, NavLinkProps } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import { Location } from 'history';
|
||||
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
||||
import { ServerWithId } from '../servers/data';
|
||||
import { isServerWithId, SelectedServer } from '../servers/data';
|
||||
import { supportsDomainRedirects } from '../utils/helpers/features';
|
||||
import './AsideMenu.scss';
|
||||
|
||||
export interface AsideMenuProps {
|
||||
selectedServer: ServerWithId;
|
||||
selectedServer: SelectedServer;
|
||||
className?: string;
|
||||
showOnMobile?: boolean;
|
||||
}
|
||||
@@ -38,7 +40,8 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
|
||||
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||
{ selectedServer, showOnMobile = false }: AsideMenuProps,
|
||||
) => {
|
||||
const serverId = selectedServer ? selectedServer.id : '';
|
||||
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
||||
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
|
||||
const asideClass = classNames('aside-menu', {
|
||||
'aside-menu--hidden': !showOnMobile,
|
||||
});
|
||||
@@ -49,30 +52,38 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||
<aside className={asideClass}>
|
||||
<nav className="nav flex-column aside-menu__nav">
|
||||
<AsideMenuItem to={buildPath('/overview')}>
|
||||
<FontAwesomeIcon icon={overviewIcon} />
|
||||
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
|
||||
<span className="aside-menu__item-text">Overview</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
||||
<FontAwesomeIcon icon={listIcon} />
|
||||
<FontAwesomeIcon fixedWidth icon={listIcon} />
|
||||
<span className="aside-menu__item-text">List short URLs</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/create-short-url')}>
|
||||
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
|
||||
<FontAwesomeIcon fixedWidth icon={createIcon} flip="horizontal" />
|
||||
<span className="aside-menu__item-text">Create short URL</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||
<FontAwesomeIcon icon={tagsIcon} />
|
||||
<FontAwesomeIcon fixedWidth icon={tagsIcon} />
|
||||
<span className="aside-menu__item-text">Manage tags</span>
|
||||
</AsideMenuItem>
|
||||
{addManageDomainsLink && (
|
||||
<AsideMenuItem to={buildPath('/manage-domains')}>
|
||||
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
|
||||
<span className="aside-menu__item-text">Manage domains</span>
|
||||
</AsideMenuItem>
|
||||
)}
|
||||
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
||||
<FontAwesomeIcon icon={editIcon} />
|
||||
<FontAwesomeIcon fixedWidth icon={editIcon} />
|
||||
<span className="aside-menu__item-text">Edit this server</span>
|
||||
</AsideMenuItem>
|
||||
<DeleteServerButton
|
||||
className="aside-menu__item aside-menu__item--danger"
|
||||
textClassName="aside-menu__item-text"
|
||||
server={selectedServer}
|
||||
/>
|
||||
{isServerWithId(selectedServer) && (
|
||||
<DeleteServerButton
|
||||
className="aside-menu__item aside-menu__item--danger"
|
||||
textClassName="aside-menu__item-text"
|
||||
server={selectedServer}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { isEmpty, values } from 'ramda';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, Row } from 'reactstrap';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import ServersListGroup from '../servers/ServersListGroup';
|
||||
import { ServersMap } from '../servers/data';
|
||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||
@@ -30,12 +32,19 @@ const Home = ({ servers }: HomeProps) => {
|
||||
</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>.
|
||||
<div className="p-4 text-center">
|
||||
<p className="mb-5">This application will help you manage your Shlink servers.</p>
|
||||
<p>
|
||||
<Link to="/server/create" className="btn btn-outline-primary btn-lg mr-2">
|
||||
<FontAwesomeIcon icon={faPlus} /> <span className="ml-1">Add a server</span>
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mb-0 mt-5">
|
||||
<ExternalLink href="https://shlink.io/documentation">
|
||||
<small>
|
||||
<span className="mr-1">Learn more about Shlink</span> <FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
</small>
|
||||
</ExternalLink>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -31,7 +31,7 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
|
||||
<Collapse navbar isOpen={isOpen}>
|
||||
<Nav navbar className="ml-auto">
|
||||
<NavItem>
|
||||
<NavLink tag={Link} to={'/settings'} active={pathname === settingsPath}>
|
||||
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
|
||||
import { supportsOrphanVisits, supportsTagVisits } from '../utils/helpers/features';
|
||||
import { supportsDomainRedirects, supportsOrphanVisits } from '../utils/helpers/features';
|
||||
import { isReachableServer } from '../servers/data';
|
||||
import NotFound from './NotFound';
|
||||
import { AsideMenuProps } from './AsideMenu';
|
||||
@@ -22,6 +22,7 @@ const MenuLayout = (
|
||||
ServerError: FC,
|
||||
Overview: FC,
|
||||
EditShortUrl: FC,
|
||||
ManageDomains: FC,
|
||||
) => withSelectedServer(({ location, selectedServer }) => {
|
||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
||||
|
||||
@@ -31,8 +32,8 @@ const MenuLayout = (
|
||||
return <ServerError />;
|
||||
}
|
||||
|
||||
const addTagsVisitsRoute = supportsTagVisits(selectedServer);
|
||||
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
|
||||
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
|
||||
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
||||
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
||||
|
||||
@@ -52,9 +53,10 @@ const MenuLayout = (
|
||||
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
||||
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||
<Route path="/server/:serverId/short-code/:shortCode/edit" component={EditShortUrl} />
|
||||
{addTagsVisitsRoute && <Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
||||
<Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />
|
||||
{addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />}
|
||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||
{addManageDomainsRoute && <Route exact path="/server/:serverId/manage-domains" component={ManageDomains} />}
|
||||
<Route
|
||||
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||
/>
|
||||
|
||||
145
src/common/react-tag-autocomplete.scss
Normal file
145
src/common/react-tag-autocomplete.scss
Normal file
@@ -0,0 +1,145 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.react-tags {
|
||||
position: relative;
|
||||
padding: 5px 0 0 6px;
|
||||
border-radius: .3rem;
|
||||
background-color: var(--input-color);
|
||||
border: 1px solid var(--input-border-color);
|
||||
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
||||
|
||||
/* shared font styles */
|
||||
font-size: 1em;
|
||||
line-height: 1.2;
|
||||
|
||||
/* clicking anywhere will focus the input */
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.react-tags.is-focused {
|
||||
box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%);
|
||||
}
|
||||
|
||||
.react-tags__tag {
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
.react-tags__selected {
|
||||
display: inline;
|
||||
vertical-align: 2px;
|
||||
}
|
||||
|
||||
.react-tags__selected-tag {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
margin: 0 6px 6px 0;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--input-border-color);
|
||||
border-radius: .25rem;
|
||||
background: #f1f1f1;
|
||||
|
||||
/* match the font styles */
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.react-tags__selected-tag:after {
|
||||
content: '\2715';
|
||||
color: #aaaaaa;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.react-tags__selected-tag:hover,
|
||||
.react-tags__selected-tag:focus {
|
||||
border-color: var(--input-border-color);
|
||||
}
|
||||
|
||||
.react-tags__search {
|
||||
display: inline-block;
|
||||
|
||||
/* match tag layout */
|
||||
padding: 6px 2px;
|
||||
margin-bottom: 5px;
|
||||
|
||||
/* prevent autoresize overflowing the container */
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $smMin) {
|
||||
.react-tags__search {
|
||||
/* this will become the offsetParent for suggestions */
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.react-tags__search-input {
|
||||
font-size: 1.25rem;
|
||||
line-height: inherit;
|
||||
color: var(--input-text-color);
|
||||
background-color: var(--input-color);
|
||||
|
||||
/* prevent autoresize overflowing the container */
|
||||
max-width: 100%;
|
||||
|
||||
/* remove styles and layout from this element */
|
||||
margin: 0 0 0 7px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.react-tags__search-input::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.react-tags__suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $smMin) {
|
||||
.react-tags__suggestions {
|
||||
width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
.react-tags__suggestions ul {
|
||||
margin: 4px -1px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
background: var(--primary-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: .25rem;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, .2);
|
||||
}
|
||||
|
||||
.react-tags__suggestions li {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.react-tags__suggestions li:not(:last-child) {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.react-tags__suggestions li mark {
|
||||
text-decoration: underline;
|
||||
background: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.react-tags__suggestions li:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--active-color);
|
||||
}
|
||||
|
||||
.react-tags__suggestions li.is-active {
|
||||
background-color: var(--active-color);
|
||||
}
|
||||
|
||||
.react-tags__suggestions li.is-disabled {
|
||||
opacity: .5;
|
||||
cursor: auto;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.react-tagsinput {
|
||||
background-color: var(--input-color);
|
||||
border: 1px solid var(--input-border-color);
|
||||
border-radius: .25rem;
|
||||
overflow: hidden;
|
||||
min-height: 2.6rem;
|
||||
padding: .5rem 0 0 1rem;
|
||||
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
||||
}
|
||||
|
||||
.react-tagsinput--focused {
|
||||
border-color: #80bdff;
|
||||
box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%);
|
||||
}
|
||||
|
||||
.react-tagsinput-tag {
|
||||
font-size: 1rem;
|
||||
background-color: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
font-weight: 400;
|
||||
margin: 0 5px 6px 0;
|
||||
padding: 6px 8px;
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.react-tagsinput-remove {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.react-tagsinput-tag span:before {
|
||||
content: '\2715';
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.react-tagsinput-input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: none;
|
||||
padding: 1px 0;
|
||||
width: 100%;
|
||||
margin-bottom: 6px;
|
||||
font-size: 1.25rem;
|
||||
color: var(--input-text-color);
|
||||
}
|
||||
|
||||
.react-tagsinput-input::placeholder {
|
||||
color: $textPlaceholder;
|
||||
}
|
||||
|
||||
.react-autosuggest__suggestion--highlighted {
|
||||
background-color: var(--active-color);
|
||||
}
|
||||
13
src/common/services/ImageDownloader.ts
Normal file
13
src/common/services/ImageDownloader.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { saveUrl } from '../../utils/helpers/files';
|
||||
|
||||
export class ImageDownloader {
|
||||
public constructor(private readonly axios: AxiosInstance, private readonly window: Window) {}
|
||||
|
||||
public async saveImage(imgUrl: string, filename: string): Promise<void> {
|
||||
const { data } = await this.axios.get(imgUrl, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(data);
|
||||
|
||||
saveUrl(this.window, url, filename);
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,17 @@ import ErrorHandler from '../ErrorHandler';
|
||||
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||
import { ImageDownloader } from './ImageDownloader';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||
// Services
|
||||
bottle.constant('window', (global as any).window);
|
||||
bottle.constant('console', global.console);
|
||||
bottle.constant('axios', axios);
|
||||
|
||||
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
|
||||
|
||||
// Components
|
||||
bottle.serviceFactory('ScrollToTop', ScrollToTop);
|
||||
bottle.decorator('ScrollToTop', withRouter);
|
||||
|
||||
@@ -38,6 +43,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||
'ServerError',
|
||||
'Overview',
|
||||
'EditShortUrl',
|
||||
'ManageDomains',
|
||||
);
|
||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
||||
bottle.decorator('MenuLayout', withRouter);
|
||||
|
||||
73
src/domains/DomainRow.tsx
Normal file
73
src/domains/DomainRow.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { FC } from 'react';
|
||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBan as forbiddenIcon,
|
||||
faCheck as defaultDomainIcon,
|
||||
faEdit as editIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { ShlinkDomain, ShlinkDomainRedirects } from '../api/types';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { OptionalString } from '../utils/utils';
|
||||
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
|
||||
|
||||
interface DomainRowProps {
|
||||
domain: ShlinkDomain;
|
||||
defaultRedirects?: ShlinkDomainRedirects;
|
||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||
}
|
||||
|
||||
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
|
||||
<span className="text-muted">
|
||||
{!fallback && <small>No redirect</small>}
|
||||
{fallback && <>{fallback} <small>(as fallback)</small></>}
|
||||
</span>
|
||||
);
|
||||
const DefaultDomain: FC = () => (
|
||||
<>
|
||||
<FontAwesomeIcon icon={defaultDomainIcon} className="text-primary" id="defaultDomainIcon" />
|
||||
<UncontrolledTooltip target="defaultDomainIcon" placement="right">Default domain</UncontrolledTooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, defaultRedirects }) => {
|
||||
const [ isOpen, toggle ] = useToggle();
|
||||
const { domain: authority, isDefault, redirects } = domain;
|
||||
const domainId = `domainEdit${authority.replace('.', '')}`;
|
||||
|
||||
return (
|
||||
<tr className="responsive-table__row">
|
||||
<td className="responsive-table__cell" data-th="Is default domain">{isDefault ? <DefaultDomain /> : ''}</td>
|
||||
<th className="responsive-table__cell" data-th="Domain">{authority}</th>
|
||||
<td className="responsive-table__cell" data-th="Base path redirect">
|
||||
{redirects?.baseUrlRedirect ?? <Nr fallback={defaultRedirects?.baseUrlRedirect} />}
|
||||
</td>
|
||||
<td className="responsive-table__cell" data-th="Regular 404 redirect">
|
||||
{redirects?.regular404Redirect ?? <Nr fallback={defaultRedirects?.regular404Redirect} />}
|
||||
</td>
|
||||
<td className="responsive-table__cell" data-th="Invalid short URL redirect">
|
||||
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
|
||||
</td>
|
||||
<td className="responsive-table__cell text-right">
|
||||
<span id={domainId}>
|
||||
<Button outline size="sm" disabled={isDefault} onClick={isDefault ? undefined : toggle}>
|
||||
<FontAwesomeIcon icon={isDefault ? forbiddenIcon : editIcon} />
|
||||
</Button>
|
||||
</span>
|
||||
{isDefault && (
|
||||
<UncontrolledTooltip target={domainId} placement="left">
|
||||
Redirects for default domain cannot be edited here.
|
||||
<br />
|
||||
Use config options or env vars directly on the server.
|
||||
</UncontrolledTooltip>
|
||||
)}
|
||||
</td>
|
||||
<EditDomainRedirectsModal
|
||||
domain={domain}
|
||||
isOpen={isOpen}
|
||||
toggle={toggle}
|
||||
editDomainRedirects={editDomainRedirects}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
71
src/domains/ManageDomains.tsx
Normal file
71
src/domains/ManageDomains.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import Message from '../utils/Message';
|
||||
import { Result } from '../utils/Result';
|
||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { ShlinkDomainRedirects } from '../api/types';
|
||||
import { DomainsList } from './reducers/domainsList';
|
||||
import { DomainRow } from './DomainRow';
|
||||
|
||||
interface ManageDomainsProps {
|
||||
listDomains: Function;
|
||||
filterDomains: (searchTerm: string) => void;
|
||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||
domainsList: DomainsList;
|
||||
}
|
||||
|
||||
const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '' ];
|
||||
|
||||
export const ManageDomains: FC<ManageDomainsProps> = (
|
||||
{ listDomains, domainsList, filterDomains, editDomainRedirects },
|
||||
) => {
|
||||
const { filteredDomains: domains, loading, error, errorData } = domainsList;
|
||||
const defaultRedirects = domains.find(({ isDefault }) => isDefault)?.redirects;
|
||||
|
||||
useEffect(() => {
|
||||
listDomains();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <Message loading />;
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<Result type="error">
|
||||
<ShlinkApiError errorData={errorData} fallbackMessage="Error loading domains :(" />
|
||||
</Result>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleCard>
|
||||
<table className="table table-hover mb-0">
|
||||
<thead className="responsive-table__header">
|
||||
<tr>{headers.map((column, index) => <th key={index}>{column}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{domains.length < 1 && <tr><td colSpan={headers.length} className="text-center">No results found</td></tr>}
|
||||
{domains.map((domain) => (
|
||||
<DomainRow
|
||||
key={domain.domain}
|
||||
domain={domain}
|
||||
editDomainRedirects={editDomainRedirects}
|
||||
defaultRedirects={defaultRedirects}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</SimpleCard>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchField className="mb-3" onChange={filterDomains} />
|
||||
{renderContent()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
72
src/domains/helpers/EditDomainRedirectsModal.tsx
Normal file
72
src/domains/helpers/EditDomainRedirectsModal.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
|
||||
import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer';
|
||||
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
|
||||
import { InfoTooltip } from '../../utils/InfoTooltip';
|
||||
|
||||
interface EditDomainRedirectsModalProps {
|
||||
domain: ShlinkDomain;
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||
}
|
||||
|
||||
const FormGroup: FC<FormGroupContainerProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
|
||||
<FormGroupContainer
|
||||
{...rest}
|
||||
required={false}
|
||||
type="url"
|
||||
placeholder="No redirect"
|
||||
className={isLast ? 'mb-0' : ''}
|
||||
/>
|
||||
);
|
||||
|
||||
export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
|
||||
{ isOpen, toggle, domain, editDomainRedirects },
|
||||
) => {
|
||||
const [ baseUrlRedirect, setBaseUrlRedirect ] = useState(domain.redirects?.baseUrlRedirect ?? '');
|
||||
const [ regular404Redirect, setRegular404Redirect ] = useState(domain.redirects?.regular404Redirect ?? '');
|
||||
const [ invalidShortUrlRedirect, setInvalidShortUrlRedirect ] = useState(
|
||||
domain.redirects?.invalidShortUrlRedirect ?? '',
|
||||
);
|
||||
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, {
|
||||
baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect),
|
||||
regular404Redirect: nonEmptyValueOrNull(regular404Redirect),
|
||||
invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect),
|
||||
}).then(toggle));
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ModalHeader toggle={toggle}>Edit redirects for <b>{domain.domain}</b></ModalHeader>
|
||||
<ModalBody>
|
||||
<FormGroup value={baseUrlRedirect} onChange={setBaseUrlRedirect}>
|
||||
<InfoTooltip className="mr-2" placement="bottom">
|
||||
Visitors accessing the base url, as in <b>https://{domain.domain}/</b>, will be redirected to this URL.
|
||||
</InfoTooltip>
|
||||
Base URL
|
||||
</FormGroup>
|
||||
<FormGroup value={regular404Redirect} onChange={setRegular404Redirect}>
|
||||
<InfoTooltip className="mr-2" placement="bottom">
|
||||
Visitors accessing a url not matching a short URL pattern, as in <b>https://{domain.domain}/???/[...]</b>,
|
||||
will be redirected to this URL.
|
||||
</InfoTooltip>
|
||||
Regular 404
|
||||
</FormGroup>
|
||||
<FormGroup value={invalidShortUrlRedirect} isLast onChange={setInvalidShortUrlRedirect}>
|
||||
<InfoTooltip className="mr-2" placement="bottom">
|
||||
Visitors accessing a url matching a short URL pattern, but not matching an existing short code, will be
|
||||
redirected to this URL.
|
||||
</InfoTooltip>
|
||||
Invalid short URL
|
||||
</FormGroup>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="link" type="button" onClick={toggle}>Cancel</Button>
|
||||
<Button color="primary">Save</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
33
src/domains/reducers/domainRedirects.ts
Normal file
33
src/domains/reducers/domainRedirects.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ShlinkDomainRedirects } from '../../api/types';
|
||||
import { GetState } from '../../container/types';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const EDIT_DOMAIN_REDIRECTS_START = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_START';
|
||||
export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR';
|
||||
export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export interface EditDomainRedirectsAction extends Action<string> {
|
||||
domain: string;
|
||||
redirects: ShlinkDomainRedirects;
|
||||
}
|
||||
|
||||
export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||
domain: string,
|
||||
domainRedirects: Partial<ShlinkDomainRedirects>,
|
||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||
dispatch({ type: EDIT_DOMAIN_REDIRECTS_START });
|
||||
const { editDomainRedirects } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
const redirects = await editDomainRedirects({ domain, ...domainRedirects });
|
||||
|
||||
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
|
||||
} catch (e) {
|
||||
dispatch<ApiErrorAction>({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) });
|
||||
}
|
||||
};
|
||||
@@ -1,35 +1,63 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { ShlinkDomain } from '../../api/types';
|
||||
import { ProblemDetailsError, ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { GetState } from '../../container/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
|
||||
|
||||
/* 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';
|
||||
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export interface DomainsList {
|
||||
domains: ShlinkDomain[];
|
||||
filteredDomains: ShlinkDomain[];
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface ListDomainsAction extends Action<string> {
|
||||
domains: ShlinkDomain[];
|
||||
}
|
||||
|
||||
interface FilterDomainsAction extends Action<string> {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
const initialState: DomainsList = {
|
||||
domains: [],
|
||||
filteredDomains: [],
|
||||
loading: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<DomainsList, ListDomainsAction>({
|
||||
export type DomainsCombinedAction = ListDomainsAction
|
||||
& ApiErrorAction
|
||||
& FilterDomainsAction
|
||||
& EditDomainRedirectsAction;
|
||||
|
||||
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
|
||||
(d: ShlinkDomain): ShlinkDomain => d.domain !== domain ? d : { ...d, redirects };
|
||||
|
||||
export default buildReducer<DomainsList, DomainsCombinedAction>({
|
||||
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
||||
[LIST_DOMAINS_ERROR]: () => ({ ...initialState, error: true }),
|
||||
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains }),
|
||||
[LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }),
|
||||
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains, filteredDomains: domains }),
|
||||
[FILTER_DOMAINS]: (state, { searchTerm }) => ({
|
||||
...state,
|
||||
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)),
|
||||
}),
|
||||
[EDIT_DOMAIN_REDIRECTS]: (state, { domain, redirects }) => ({
|
||||
...state,
|
||||
domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||
}),
|
||||
}, initialState);
|
||||
|
||||
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
||||
@@ -44,6 +72,8 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
|
||||
|
||||
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
|
||||
} catch (e) {
|
||||
dispatch({ type: LIST_DOMAINS_ERROR });
|
||||
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
||||
}
|
||||
};
|
||||
|
||||
export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm });
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import Bottle from 'bottlejs';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { listDomains } from '../reducers/domainsList';
|
||||
import { filterDomains, listDomains } from '../reducers/domainsList';
|
||||
import { DomainSelector } from '../DomainSelector';
|
||||
import { ManageDomains } from '../ManageDomains';
|
||||
import { editDomainRedirects } from '../reducers/domainRedirects';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory('DomainSelector', () => DomainSelector);
|
||||
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ]));
|
||||
|
||||
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
||||
bottle.decorator('ManageDomains', connect(
|
||||
[ 'domainsList' ],
|
||||
[ 'listDomains', 'filterDomains', 'editDomainRedirects' ],
|
||||
));
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('filterDomains', () => filterDomains);
|
||||
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
@import './utils/base';
|
||||
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
||||
@import './common/react-tagsinput.scss';
|
||||
@import './common/react-tag-autocomplete.scss';
|
||||
@import './theme/theme';
|
||||
@import './utils/table/ResponsiveTable';
|
||||
@import './utils/StickyCardPaginator';
|
||||
|
||||
* {
|
||||
outline: none !important;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export class Topics {
|
||||
public static visits = () => 'https://shlink.io/new-visit';
|
||||
public static readonly visits = 'https://shlink.io/new-visit';
|
||||
|
||||
public static shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`;
|
||||
public static readonly orphanVisits = 'https://shlink.io/new-orphan-visit';
|
||||
|
||||
public static orphanVisits = () => 'https://shlink.io/new-orphan-visit';
|
||||
public static readonly shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<D
|
||||
return (
|
||||
<>
|
||||
<span className={className} onClick={showModal}>
|
||||
{!children && <FontAwesomeIcon icon={deleteIcon} />}
|
||||
{!children && <FontAwesomeIcon fixedWidth icon={deleteIcon} />}
|
||||
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -55,14 +55,7 @@ export const Overview = (
|
||||
<div className="col-md-6 col-xl-3">
|
||||
<Card className="overview__card mb-3" 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>
|
||||
<CardText tag="h2">{loadingVisits ? 'Loading...' : prettify(visitsCount)}</CardText>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-md-6 col-xl-3">
|
||||
@@ -120,4 +113,4 @@ export const Overview = (
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}, () => [ Topics.visits(), Topics.orphanVisits() ]);
|
||||
}, () => [ Topics.visits, Topics.orphanVisits ]);
|
||||
|
||||
@@ -15,7 +15,7 @@ const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, select
|
||||
const serversList = values(servers);
|
||||
const createServerItem = (
|
||||
<DropdownItem tag={Link} to="/server/create">
|
||||
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add server</span>
|
||||
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add a server</span>
|
||||
</DropdownItem>
|
||||
);
|
||||
|
||||
|
||||
@@ -40,3 +40,5 @@ export const isReachableServer = (server: SelectedServer): server is ReachableSe
|
||||
|
||||
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
|
||||
!!server?.hasOwnProperty('serverNotFound');
|
||||
|
||||
export const getServerId = (server: SelectedServer) => isServerWithId(server) ? server.id : '';
|
||||
|
||||
@@ -7,7 +7,7 @@ type Ref<T> = RefObject<T> | MutableRefObject<T>;
|
||||
|
||||
export interface ImportServersBtnProps {
|
||||
onImport?: () => void;
|
||||
onImportError?: () => void;
|
||||
onImportError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC, ReactNode, useEffect, useState } from 'react';
|
||||
import { FormGroupContainer } from '../../utils/FormGroupContainer';
|
||||
import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer';
|
||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||
import { ServerData } from '../data';
|
||||
import { SimpleCard } from '../../utils/SimpleCard';
|
||||
@@ -11,6 +11,9 @@ interface ServerFormProps {
|
||||
title?: ReactNode;
|
||||
}
|
||||
|
||||
const FormGroup: FC<FormGroupContainerProps> = (props) =>
|
||||
<FormGroupContainer {...props} labelClassName="create-server__label" />;
|
||||
|
||||
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
|
||||
const [ name, setName ] = useState('');
|
||||
const [ url, setUrl ] = useState('');
|
||||
@@ -26,9 +29,9 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
|
||||
return (
|
||||
<form className="server-form" onSubmit={handleSubmit}>
|
||||
<SimpleCard className="mb-3" 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>
|
||||
<FormGroup value={name} onChange={setName}>Name</FormGroup>
|
||||
<FormGroup type="url" value={url} onChange={setUrl}>URL</FormGroup>
|
||||
<FormGroup value={apiKey} onChange={setApiKey}>APIkey</FormGroup>
|
||||
</SimpleCard>
|
||||
|
||||
<div className="text-right">{children}</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { dissoc, values } from 'ramda';
|
||||
import { CsvJson } from 'csvjson';
|
||||
import LocalStorage from '../../utils/services/LocalStorage';
|
||||
import { ServersMap } from '../data';
|
||||
import { saveCsv } from '../../utils/helpers/csv';
|
||||
import { saveCsv } from '../../utils/helpers/files';
|
||||
|
||||
const SERVERS_FILENAME = 'shlink-servers.csv';
|
||||
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
import { CsvJson } from 'csvjson';
|
||||
import { ServerData } from '../data';
|
||||
|
||||
interface CsvFile extends File {
|
||||
type: 'text/csv' | 'text/comma-separated-values' | 'application/csv';
|
||||
}
|
||||
const validateServer = (server: any): server is ServerData =>
|
||||
typeof server.url === 'string' && typeof server.apiKey === 'string' && typeof server.name === 'string';
|
||||
|
||||
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);
|
||||
const validateServers = (servers: any): servers is ServerData[] =>
|
||||
Array.isArray(servers) && servers.every(validateServer);
|
||||
|
||||
export default class ServersImporter {
|
||||
public constructor(private readonly csvjson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
||||
public constructor(private readonly csvJson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
||||
|
||||
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
||||
if (!isCsv(file)) {
|
||||
throw new Error('No file provided or file is not a CSV');
|
||||
if (!file) {
|
||||
throw new Error('No file provided');
|
||||
}
|
||||
|
||||
const reader = this.fileReaderFactory();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
reader.addEventListener('loadend', (e: ProgressEvent<FileReader>) => {
|
||||
const content = e.target?.result?.toString() ?? '';
|
||||
const servers = this.csvjson.toObject<ServerData>(content);
|
||||
try {
|
||||
// TODO Read as stream, otherwise, if the file is too big, this will block the browser tab
|
||||
const content = e.target?.result?.toString() ?? '';
|
||||
const servers = this.csvJson.toObject(content);
|
||||
|
||||
resolve(servers);
|
||||
if (!validateServers(servers)) {
|
||||
throw new Error('Provided file does not have the right format.');
|
||||
}
|
||||
|
||||
resolve(servers);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ const RealTimeUpdates = (
|
||||
placeholder="Immediate"
|
||||
disabled={!realTimeUpdates.enabled}
|
||||
value={intervalValue(realTimeUpdates.interval)}
|
||||
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
|
||||
onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))}
|
||||
/>
|
||||
{realTimeUpdates.enabled && (
|
||||
<small className="form-text text-muted">
|
||||
|
||||
@@ -20,8 +20,8 @@ const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC,
|
||||
<NoMenuLayout>
|
||||
<SettingsSections
|
||||
items={[
|
||||
[ <UserInterface />, <ShortUrlCreation /> ], // eslint-disable-line react/jsx-key
|
||||
[ <Visits />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
|
||||
[ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key
|
||||
[ <ShortUrlCreation />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
|
||||
]}
|
||||
/>
|
||||
</NoMenuLayout>
|
||||
|
||||
@@ -1,29 +1,62 @@
|
||||
import { FC } from 'react';
|
||||
import { FormGroup } from 'reactstrap';
|
||||
import { FC, ReactNode } from 'react';
|
||||
import { DropdownItem, FormGroup } from 'reactstrap';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||
import { Settings, ShortUrlCreationSettings } from './reducers/settings';
|
||||
import { DropdownBtn } from '../utils/DropdownBtn';
|
||||
import { Settings, ShortUrlCreationSettings, TagFilteringMode } from './reducers/settings';
|
||||
|
||||
interface ShortUrlCreationProps {
|
||||
settings: Settings;
|
||||
setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void;
|
||||
}
|
||||
|
||||
export const ShortUrlCreation: FC<ShortUrlCreationProps> = (
|
||||
{ settings: { shortUrlCreation }, setShortUrlCreationSettings },
|
||||
) => (
|
||||
<SimpleCard title="Short URLs creation" className="h-100">
|
||||
<FormGroup className="mb-0">
|
||||
<ToggleSwitch
|
||||
checked={shortUrlCreation?.validateUrls ?? false}
|
||||
onChange={(validateUrls) => setShortUrlCreationSettings({ validateUrls })}
|
||||
>
|
||||
By default, request validation on long URLs when creating new short URLs.
|
||||
const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
|
||||
tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input';
|
||||
const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode =>
|
||||
tagFilteringMode === 'includes'
|
||||
? <>The list of suggested tags will contain existing ones <b>including</b> provided input.</>
|
||||
: <>The list of suggested tags will contain existing ones <b>starting with</b> provided input.</>;
|
||||
|
||||
export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
|
||||
const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false };
|
||||
const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings(
|
||||
{ ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode },
|
||||
);
|
||||
|
||||
return (
|
||||
<SimpleCard title="Short URLs creation" className="h-100">
|
||||
<FormGroup>
|
||||
<ToggleSwitch
|
||||
checked={shortUrlCreation.validateUrls ?? false}
|
||||
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
|
||||
>
|
||||
By default, request validation on long URLs when creating new short URLs.
|
||||
<small className="form-text text-muted">
|
||||
The initial state of the <b>Validate URL</b> checkbox will
|
||||
be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>.
|
||||
</small>
|
||||
</ToggleSwitch>
|
||||
</FormGroup>
|
||||
<FormGroup className="mb-0">
|
||||
<label>Tag suggestions search mode:</label>
|
||||
<DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}>
|
||||
<DropdownItem
|
||||
active={!shortUrlCreation.tagFilteringMode || shortUrlCreation.tagFilteringMode === 'startsWith'}
|
||||
onClick={changeTagsFilteringMode('startsWith')}
|
||||
>
|
||||
{tagFilteringModeText('startsWith')}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
active={shortUrlCreation.tagFilteringMode === 'includes'}
|
||||
onClick={changeTagsFilteringMode('includes')}
|
||||
>
|
||||
{tagFilteringModeText('includes')}
|
||||
</DropdownItem>
|
||||
</DropdownBtn>
|
||||
<small className="form-text text-muted">
|
||||
The initial state of the <b>Validate URL</b> checkbox will
|
||||
be <b>{shortUrlCreation?.validateUrls ? 'checked' : 'unchecked'}</b>.
|
||||
{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}
|
||||
</small>
|
||||
</ToggleSwitch>
|
||||
</FormGroup>
|
||||
</SimpleCard>
|
||||
);
|
||||
</FormGroup>
|
||||
</SimpleCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { FC } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FormGroup } from 'reactstrap';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||
import { changeThemeInMarkup, Theme } from '../utils/theme';
|
||||
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
|
||||
import { capitalize } from '../utils/utils';
|
||||
import { Settings, UiSettings } from './reducers/settings';
|
||||
import './UserInterface.scss';
|
||||
|
||||
@@ -14,17 +17,28 @@ interface UserInterfaceProps {
|
||||
|
||||
export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
|
||||
<SimpleCard title="User interface" className="h-100">
|
||||
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
||||
<ToggleSwitch
|
||||
checked={ui?.theme === 'dark'}
|
||||
onChange={(useDarkTheme) => {
|
||||
const theme: Theme = useDarkTheme ? 'dark' : 'light';
|
||||
<FormGroup>
|
||||
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
||||
<ToggleSwitch
|
||||
checked={ui?.theme === 'dark'}
|
||||
onChange={(useDarkTheme) => {
|
||||
const theme: Theme = useDarkTheme ? 'dark' : 'light';
|
||||
|
||||
setUiSettings({ theme });
|
||||
changeThemeInMarkup(theme);
|
||||
}}
|
||||
>
|
||||
Use dark theme.
|
||||
</ToggleSwitch>
|
||||
setUiSettings({ ...ui, theme });
|
||||
changeThemeInMarkup(theme);
|
||||
}}
|
||||
>
|
||||
Use dark theme.
|
||||
</ToggleSwitch>
|
||||
</FormGroup>
|
||||
<FormGroup className="mb-0">
|
||||
<label>Default display mode when managing tags:</label>
|
||||
<TagsModeDropdown
|
||||
mode={ui?.tagsMode ?? 'cards'}
|
||||
renderTitle={(tagsMode) => capitalize(tagsMode)}
|
||||
onChange={(tagsMode) => setUiSettings({ ...ui ?? { theme: 'light' }, tagsMode })}
|
||||
/>
|
||||
<small className="form-text text-muted">Tags will be displayed as <b>{ui?.tagsMode ?? 'cards'}</b>.</small>
|
||||
</FormGroup>
|
||||
</SimpleCard>
|
||||
);
|
||||
|
||||
@@ -12,17 +12,23 @@ export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS';
|
||||
* optional, as old instances of the app will load partial objects from local storage until it is saved again.
|
||||
*/
|
||||
|
||||
interface RealTimeUpdatesSettings {
|
||||
export interface RealTimeUpdatesSettings {
|
||||
enabled: boolean;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export type TagFilteringMode = 'startsWith' | 'includes';
|
||||
|
||||
export interface ShortUrlCreationSettings {
|
||||
validateUrls: boolean;
|
||||
tagFilteringMode?: TagFilteringMode;
|
||||
}
|
||||
|
||||
export type TagsMode = 'cards' | 'list';
|
||||
|
||||
export interface UiSettings {
|
||||
theme: Theme;
|
||||
tagsMode?: TagsMode;
|
||||
}
|
||||
|
||||
export interface VisitsSettings {
|
||||
|
||||
@@ -41,6 +41,7 @@ const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSetting
|
||||
validSince: shortUrl.meta.validSince ?? undefined,
|
||||
validUntil: shortUrl.meta.validUntil ?? undefined,
|
||||
maxVisits: shortUrl.meta.maxVisits ?? undefined,
|
||||
crawlable: shortUrl.crawlable,
|
||||
validateUrl,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Link } from 'react-router-dom';
|
||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||
import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination';
|
||||
import { ShlinkPaginator } from '../api/types';
|
||||
import './Paginator.scss';
|
||||
|
||||
interface PaginatorProps {
|
||||
paginator?: ShlinkPaginator;
|
||||
@@ -33,7 +32,7 @@ const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
||||
));
|
||||
|
||||
return (
|
||||
<Pagination className="short-urls-paginator" listClassName="flex-wrap justify-content-center mb-0">
|
||||
<Pagination className="sticky-card-paginator" listClassName="flex-wrap justify-content-center mb-0">
|
||||
<PaginationItem disabled={currentPage === 1}>
|
||||
<PaginationLink
|
||||
previous
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { isEmpty, pipe } from 'ramda';
|
||||
import moment from 'moment';
|
||||
import { parseISO } from 'date-fns';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import Tag from '../tags/helpers/Tag';
|
||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||
@@ -16,7 +16,7 @@ interface SearchBarProps {
|
||||
shortUrlsListParams: ShortUrlsListParams;
|
||||
}
|
||||
|
||||
const dateOrNull = (date?: string) => date ? moment(date) : null;
|
||||
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
|
||||
|
||||
const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrlsListParams }: SearchBarProps) => {
|
||||
const selectedTags = shortUrlsListParams.tags ?? [];
|
||||
@@ -30,11 +30,7 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl
|
||||
|
||||
return (
|
||||
<div className="search-bar-container">
|
||||
<SearchField
|
||||
onChange={
|
||||
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
||||
}
|
||||
/>
|
||||
<SearchField onChange={(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })} />
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="row">
|
||||
|
||||
@@ -2,15 +2,10 @@ import { FC, useEffect, useState } from 'react';
|
||||
import { InputType } from 'reactstrap/lib/Input';
|
||||
import { Button, FormGroup, Input, Row } from 'reactstrap';
|
||||
import { isEmpty, pipe, replace, trim } from 'ramda';
|
||||
import m from 'moment';
|
||||
import classNames from 'classnames';
|
||||
import { parseISO } from 'date-fns';
|
||||
import DateInput, { DateInputProps } from '../utils/DateInput';
|
||||
import {
|
||||
supportsListingDomains,
|
||||
supportsSettingShortCodeLength,
|
||||
supportsShortUrlTitle,
|
||||
supportsValidateUrl,
|
||||
} from '../utils/helpers/features';
|
||||
import { supportsCrawlableVisits, supportsShortUrlTitle } from '../utils/helpers/features';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
|
||||
import Checkbox from '../utils/Checkbox';
|
||||
@@ -20,6 +15,7 @@ import { DomainSelectorProps } from '../domains/DomainSelector';
|
||||
import { formatIsoDate } from '../utils/helpers/date';
|
||||
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
||||
import { ShortUrlData } from './data';
|
||||
import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup';
|
||||
import './ShortUrlForm.scss';
|
||||
|
||||
export type Mode = 'create' | 'create-basic' | 'edit';
|
||||
@@ -36,11 +32,12 @@ export interface ShortUrlFormProps {
|
||||
}
|
||||
|
||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||
const toDate = (date?: string | Date): Date | undefined => typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
export const ShortUrlForm = (
|
||||
TagsSelector: FC<TagsSelectorProps>,
|
||||
DomainSelector: FC<DomainSelectorProps>,
|
||||
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => { // eslint-disable-line complexity
|
||||
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => {
|
||||
const [ shortUrlData, setShortUrlData ] = useState(initialState);
|
||||
const isEdit = mode === 'edit';
|
||||
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
|
||||
@@ -72,7 +69,7 @@ export const ShortUrlForm = (
|
||||
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
|
||||
<div className="form-group">
|
||||
<DateInput
|
||||
selected={shortUrlData[id] ? m(shortUrlData[id]) : null}
|
||||
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
|
||||
placeholderText={placeholder}
|
||||
isClearable
|
||||
onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })}
|
||||
@@ -94,21 +91,18 @@ export const ShortUrlForm = (
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<TagsSelector tags={shortUrlData.tags ?? []} onChange={changeTags} />
|
||||
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
|
||||
</FormGroup>
|
||||
</>
|
||||
);
|
||||
|
||||
const showDomainSelector = supportsListingDomains(selectedServer);
|
||||
const disableShortCodeLength = !supportsSettingShortCodeLength(selectedServer);
|
||||
const supportsTitle = supportsShortUrlTitle(selectedServer);
|
||||
const showCustomizeCard = supportsTitle || !isEdit;
|
||||
const limitAccessCardClasses = classNames('mb-3', {
|
||||
'col-sm-6': showCustomizeCard,
|
||||
'col-sm-12': !showCustomizeCard,
|
||||
});
|
||||
const showValidateUrl = supportsValidateUrl(selectedServer);
|
||||
const showExtraValidationsCard = showValidateUrl || !isEdit;
|
||||
const showCrawlableControl = supportsCrawlableVisits(selectedServer);
|
||||
|
||||
return (
|
||||
<form className="short-url-form" onSubmit={submit}>
|
||||
@@ -135,22 +129,16 @@ export const ShortUrlForm = (
|
||||
<div className="col-lg-6">
|
||||
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
||||
min: 4,
|
||||
disabled: disableShortCodeLength || hasValue(shortUrlData.customSlug),
|
||||
...disableShortCodeLength && {
|
||||
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
|
||||
},
|
||||
disabled: hasValue(shortUrlData.customSlug),
|
||||
})}
|
||||
</div>
|
||||
</Row>
|
||||
{!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text')}
|
||||
{showDomainSelector && (
|
||||
<FormGroup>
|
||||
<DomainSelector
|
||||
value={shortUrlData.domain}
|
||||
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
<FormGroup>
|
||||
<DomainSelector
|
||||
value={shortUrlData.domain}
|
||||
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
|
||||
/>
|
||||
</FormGroup>
|
||||
</>
|
||||
)}
|
||||
</SimpleCard>
|
||||
@@ -160,46 +148,43 @@ export const ShortUrlForm = (
|
||||
<div className={limitAccessCardClasses}>
|
||||
<SimpleCard title="Limit access to the short URL">
|
||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? m(shortUrlData.validUntil) : undefined })}
|
||||
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? m(shortUrlData.validSince) : undefined })}
|
||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? toDate(shortUrlData.validUntil) : undefined })}
|
||||
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? toDate(shortUrlData.validSince) : undefined })}
|
||||
</SimpleCard>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{showExtraValidationsCard && (
|
||||
<SimpleCard title="Extra validations" className="mb-3">
|
||||
{!isEdit && (
|
||||
<p>
|
||||
Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all
|
||||
provided data.
|
||||
</p>
|
||||
)}
|
||||
{showValidateUrl && (
|
||||
<p>
|
||||
<Checkbox
|
||||
inline
|
||||
checked={shortUrlData.validateUrl}
|
||||
onChange={(validateUrl) => setShortUrlData({ ...shortUrlData, validateUrl })}
|
||||
>
|
||||
Validate URL
|
||||
</Checkbox>
|
||||
</p>
|
||||
)}
|
||||
{!isEdit && (
|
||||
<p>
|
||||
<Checkbox
|
||||
inline
|
||||
className="mr-2"
|
||||
checked={shortUrlData.findIfExists}
|
||||
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
|
||||
>
|
||||
Use existing URL if found
|
||||
</Checkbox>
|
||||
<UseExistingIfFoundInfoIcon />
|
||||
</p>
|
||||
)}
|
||||
</SimpleCard>
|
||||
)}
|
||||
<SimpleCard title="Extra checks" className="mb-3">
|
||||
<ShortUrlFormCheckboxGroup
|
||||
infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible."
|
||||
checked={shortUrlData.validateUrl}
|
||||
onChange={(validateUrl) => setShortUrlData({ ...shortUrlData, validateUrl })}
|
||||
>
|
||||
Validate URL
|
||||
</ShortUrlFormCheckboxGroup>
|
||||
{showCrawlableControl && (
|
||||
<ShortUrlFormCheckboxGroup
|
||||
infoTooltip="This short URL will be included in the robots.txt for your Shlink instance, allowing web crawlers (like Google) to index it."
|
||||
checked={shortUrlData.crawlable}
|
||||
onChange={(crawlable) => setShortUrlData({ ...shortUrlData, crawlable })}
|
||||
>
|
||||
Make it crawlable
|
||||
</ShortUrlFormCheckboxGroup>
|
||||
)}
|
||||
{!isEdit && (
|
||||
<p>
|
||||
<Checkbox
|
||||
inline
|
||||
className="mr-2"
|
||||
checked={shortUrlData.findIfExists}
|
||||
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
|
||||
>
|
||||
Use existing URL if found
|
||||
</Checkbox>
|
||||
<UseExistingIfFoundInfoIcon />
|
||||
</p>
|
||||
)}
|
||||
</SimpleCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercur
|
||||
|
||||
useEffect(() => {
|
||||
const { tag } = parseQuery<{ tag?: string }>(location.search);
|
||||
const tags = tag ? [ tag ] : shortUrlsListParams.tags;
|
||||
const tags = tag ? [ decodeURIComponent(tag) ] : shortUrlsListParams.tags;
|
||||
|
||||
refreshList({ page: match.params.page, tags, itemsPerPage: undefined });
|
||||
|
||||
@@ -99,6 +99,6 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercur
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}, () => [ Topics.visits() ]);
|
||||
}, () => [ Topics.visits ]);
|
||||
|
||||
export default ShortUrlsList;
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.short-urls-table__header {
|
||||
@media (max-width: $responsiveTableBreakpoint) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.short-urls-table__header-cell--with-action {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||
|
||||
return (
|
||||
<table className={tableClasses}>
|
||||
<thead className="short-urls-table__header">
|
||||
<thead className="responsive-table__header short-urls-table__header">
|
||||
<tr>
|
||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
|
||||
Created at
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import * as m from 'moment';
|
||||
import { Nullable, OptionalString } from '../../utils/utils';
|
||||
|
||||
export interface EditShortUrlData {
|
||||
longUrl?: string;
|
||||
tags?: string[];
|
||||
title?: string;
|
||||
validSince?: m.Moment | string | null;
|
||||
validUntil?: m.Moment | string | null;
|
||||
validSince?: Date | string | null;
|
||||
validUntil?: Date | string | null;
|
||||
maxVisits?: number | null;
|
||||
validateUrl?: boolean;
|
||||
crawlable?: boolean;
|
||||
}
|
||||
|
||||
export interface ShortUrlData extends EditShortUrlData {
|
||||
@@ -29,6 +29,7 @@ export interface ShortUrl {
|
||||
tags: string[];
|
||||
domain: string | null;
|
||||
title?: string | null;
|
||||
crawlable?: boolean;
|
||||
}
|
||||
|
||||
export interface ShortUrlMeta {
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Modal, DropdownItem, FormGroup, ModalBody, ModalHeader, Row } from 'reactstrap';
|
||||
import { FC, useMemo, useState } from 'react';
|
||||
import { Modal, FormGroup, ModalBody, ModalHeader, Row, Button } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import classNames from 'classnames';
|
||||
import { ShortUrlModalProps } from '../data';
|
||||
import { SelectedServer } from '../../servers/data';
|
||||
import { DropdownBtn } from '../../utils/DropdownBtn';
|
||||
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
||||
import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat } from '../../utils/helpers/qrCodes';
|
||||
import { supportsQrCodeSizeInQuery, supportsQrCodeSvgFormat, supportsQrCodeMargin } from '../../utils/helpers/features';
|
||||
import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
|
||||
import {
|
||||
supportsQrCodeSizeInQuery,
|
||||
supportsQrCodeMargin,
|
||||
supportsQrErrorCorrection,
|
||||
} from '../../utils/helpers/features';
|
||||
import { ImageDownloader } from '../../common/services/ImageDownloader';
|
||||
import { Versions } from '../../utils/helpers/version';
|
||||
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
|
||||
import './QrCodeModal.scss';
|
||||
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
|
||||
|
||||
interface QrCodeModalConnectProps extends ShortUrlModalProps {
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps) => {
|
||||
const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Versions>) => ( // eslint-disable-line
|
||||
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps,
|
||||
) => {
|
||||
const [ size, setSize ] = useState(300);
|
||||
const [ margin, setMargin ] = useState(0);
|
||||
const [ format, setFormat ] = useState<QrCodeFormat>('png');
|
||||
const [ errorCorrection, setErrorCorrection ] = useState<QrErrorCorrection>('L');
|
||||
const capabilities: QrCodeCapabilities = useMemo(() => ({
|
||||
useSizeInPath: !supportsQrCodeSizeInQuery(selectedServer),
|
||||
svgIsSupported: supportsQrCodeSvgFormat(selectedServer),
|
||||
marginIsSupported: supportsQrCodeMargin(selectedServer),
|
||||
errorCorrectionIsSupported: supportsQrErrorCorrection(selectedServer),
|
||||
}), [ selectedServer ]);
|
||||
const willRenderThreeControls = capabilities.marginIsSupported !== capabilities.errorCorrectionIsSupported;
|
||||
const qrCodeUrl = useMemo(
|
||||
() => buildQrCodeUrl(shortUrl, { size, format, margin }, capabilities),
|
||||
[ shortUrl, size, format, margin, capabilities ],
|
||||
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities),
|
||||
[ shortUrl, size, format, margin, errorCorrection, capabilities ],
|
||||
);
|
||||
const totalSize = useMemo(() => size + margin, [ size, margin ]);
|
||||
const modalSize = useMemo(() => {
|
||||
@@ -42,60 +55,61 @@ const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }:
|
||||
QR code for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<Row className="mb-2">
|
||||
<div
|
||||
className={classNames({
|
||||
'col-md-4': capabilities.marginIsSupported && capabilities.svgIsSupported,
|
||||
'col-md-6': (!capabilities.marginIsSupported && capabilities.svgIsSupported) || (capabilities.marginIsSupported && !capabilities.svgIsSupported),
|
||||
'col-12': !capabilities.marginIsSupported && !capabilities.svgIsSupported,
|
||||
})}
|
||||
<Row>
|
||||
<FormGroup
|
||||
className={classNames({ 'col-md-4': willRenderThreeControls, 'col-md-6': !willRenderThreeControls })}
|
||||
>
|
||||
<FormGroup>
|
||||
<label className="mb-0">Size: {size}px</label>
|
||||
<label className="mb-0">Size: {size}px</label>
|
||||
<input
|
||||
type="range"
|
||||
className="form-control-range"
|
||||
value={size}
|
||||
step={10}
|
||||
min={50}
|
||||
max={1000}
|
||||
onChange={(e) => setSize(Number(e.target.value))}
|
||||
/>
|
||||
</FormGroup>
|
||||
{capabilities.marginIsSupported && (
|
||||
<FormGroup className={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}>
|
||||
<label className="mb-0">Margin: {margin}px</label>
|
||||
<input
|
||||
type="range"
|
||||
className="form-control-range"
|
||||
value={size}
|
||||
step={10}
|
||||
min={50}
|
||||
max={1000}
|
||||
onChange={(e) => setSize(Number(e.target.value))}
|
||||
value={margin}
|
||||
step={1}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => setMargin(Number(e.target.value))}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
{capabilities.marginIsSupported && (
|
||||
<div className={capabilities.svgIsSupported ? 'col-md-4' : 'col-md-6'}>
|
||||
<FormGroup>
|
||||
<label className="mb-0">Margin: {margin}px</label>
|
||||
<input
|
||||
type="range"
|
||||
className="form-control-range"
|
||||
value={margin}
|
||||
step={1}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => setMargin(Number(e.target.value))}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
)}
|
||||
{capabilities.svgIsSupported && (
|
||||
<div className={capabilities.marginIsSupported ? 'col-md-4' : 'col-md-6'}>
|
||||
<DropdownBtn text={`Format (${format})`}>
|
||||
<DropdownItem active={format === 'png'} onClick={() => setFormat('png')}>PNG</DropdownItem>
|
||||
<DropdownItem active={format === 'svg'} onClick={() => setFormat('svg')}>SVG</DropdownItem>
|
||||
</DropdownBtn>
|
||||
</div>
|
||||
<FormGroup className={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}>
|
||||
<QrFormatDropdown format={format} setFormat={setFormat} />
|
||||
</FormGroup>
|
||||
{capabilities.errorCorrectionIsSupported && (
|
||||
<FormGroup className="col-md-6">
|
||||
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} setErrorCorrection={setErrorCorrection} />
|
||||
</FormGroup>
|
||||
)}
|
||||
</Row>
|
||||
<div className="text-center">
|
||||
<div className="mb-3">
|
||||
<div>QR code URL:</div>
|
||||
<ExternalLink href={qrCodeUrl} />
|
||||
<CopyToClipboardIcon text={qrCodeUrl} />
|
||||
</div>
|
||||
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />
|
||||
<div className="mt-2">{size}x{size}</div>
|
||||
<ForServerVersion minVersion="2.9.0">
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
onClick={async () => imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`)}
|
||||
>
|
||||
Download <FontAwesomeIcon icon={downloadIcon} className="ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</ForServerVersion>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
|
||||
20
src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx
Normal file
20
src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ChangeEvent, FC } from 'react';
|
||||
import Checkbox from '../../utils/Checkbox';
|
||||
import { InfoTooltip } from '../../utils/InfoTooltip';
|
||||
|
||||
interface ShortUrlFormCheckboxGroupProps {
|
||||
checked?: boolean;
|
||||
onChange?: (checked: boolean, e: ChangeEvent<HTMLInputElement>) => void;
|
||||
infoTooltip?: string;
|
||||
}
|
||||
|
||||
export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
|
||||
{ children, infoTooltip, checked, onChange },
|
||||
) => (
|
||||
<p>
|
||||
<Checkbox inline checked={checked} className={infoTooltip ? 'mr-2' : ''} onChange={onChange}>
|
||||
{children}
|
||||
</Checkbox>
|
||||
{infoTooltip && <InfoTooltip placement="right">{infoTooltip}</InfoTooltip>}
|
||||
</p>
|
||||
);
|
||||
@@ -1,39 +1,8 @@
|
||||
@import '../../utils/base';
|
||||
@import '../../utils/mixins/vertical-align';
|
||||
|
||||
.short-urls-row {
|
||||
@media (max-width: $responsiveTableBreakpoint) {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.short-urls-row__cell.short-urls-row__cell {
|
||||
vertical-align: middle !important;
|
||||
|
||||
@media (max-width: $responsiveTableBreakpoint) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding: .5rem;
|
||||
font-size: .9rem;
|
||||
|
||||
&:before {
|
||||
content: attr(data-th);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
position: absolute;
|
||||
top: 3.5px;
|
||||
right: .5rem;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.short-urls-row__cell--break {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isEmpty } from 'ramda';
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
import Moment from 'react-moment';
|
||||
import { isEmpty } from 'ramda';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||
import { StateFlagTimeout } from '../../utils/helpers/hooks';
|
||||
@@ -8,6 +7,7 @@ import Tag from '../../tags/helpers/Tag';
|
||||
import { SelectedServer } from '../../servers/data';
|
||||
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
||||
import { ShortUrl } from '../data';
|
||||
import { Time } from '../../utils/Time';
|
||||
import ShortUrlVisitsCount from './ShortUrlVisitsCount';
|
||||
import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu';
|
||||
import './ShortUrlsRow.scss';
|
||||
@@ -51,11 +51,11 @@ const ShortUrlsRow = (
|
||||
}, [ shortUrl.visitsCount ]);
|
||||
|
||||
return (
|
||||
<tr className="short-urls-row">
|
||||
<td className="indivisible short-urls-row__cell" data-th="Created at: ">
|
||||
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
|
||||
<tr className="responsive-table__row">
|
||||
<td className="indivisible short-urls-row__cell responsive-table__cell" data-th="Created at">
|
||||
<Time date={shortUrl.dateCreated} />
|
||||
</td>
|
||||
<td className="short-urls-row__cell" data-th="Short URL: ">
|
||||
<td className="responsive-table__cell short-urls-row__cell" data-th="Short URL">
|
||||
<span className="indivisible short-urls-row__cell--relative">
|
||||
<ExternalLink href={shortUrl.shortUrl} />
|
||||
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
|
||||
@@ -64,16 +64,16 @@ const ShortUrlsRow = (
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="short-urls-row__cell short-urls-row__cell--break" data-th={`${shortUrl.title ? 'Title' : 'Long URL'}: `}>
|
||||
<td className="responsive-table__cell short-urls-row__cell short-urls-row__cell--break" data-th={`${shortUrl.title ? 'Title' : 'Long URL'}`}>
|
||||
<ExternalLink href={shortUrl.longUrl}>{shortUrl.title ?? shortUrl.longUrl}</ExternalLink>
|
||||
</td>
|
||||
{shortUrl.title && (
|
||||
<td className="short-urls-row__cell d-lg-none" data-th="Long URL: ">
|
||||
<td className="short-urls-row__cell responsive-table__cell short-urls-row__cell--break d-lg-none" data-th="Long URL">
|
||||
<ExternalLink href={shortUrl.longUrl} />
|
||||
</td>
|
||||
)}
|
||||
<td className="short-urls-row__cell" data-th="Tags: ">{renderTags(shortUrl.tags)}</td>
|
||||
<td className="short-urls-row__cell text-md-right" data-th="Visits: ">
|
||||
<td className="responsive-table__cell short-urls-row__cell" data-th="Tags">{renderTags(shortUrl.tags)}</td>
|
||||
<td className="responsive-table__cell short-urls-row__cell text-lg-right" data-th="Visits">
|
||||
<ShortUrlVisitsCount
|
||||
visitsCount={shortUrl.visitsCount}
|
||||
shortUrl={shortUrl}
|
||||
@@ -81,7 +81,7 @@ const ShortUrlsRow = (
|
||||
active={active}
|
||||
/>
|
||||
</td>
|
||||
<td className="short-urls-row__cell">
|
||||
<td className="responsive-table__cell short-urls-row__cell">
|
||||
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
@import '../../utils/base';
|
||||
|
||||
.short-urls-row-menu__dropdown-toggle:after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.short-urls-row-menu__dropdown-toggle--hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.short-urls-row-menu__dropdown-item--danger.short-urls-row-menu__dropdown-item--danger {
|
||||
color: $dangerColor;
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import {
|
||||
faChartPie as pieChartIcon,
|
||||
faEllipsisV as menuIcon,
|
||||
faQrcode as qrIcon,
|
||||
faMinusCircle as deleteIcon,
|
||||
faEdit as editIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { FC } from 'react';
|
||||
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { ShortUrl, ShortUrlModalProps } from '../data';
|
||||
import { SelectedServer } from '../../servers/data';
|
||||
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
||||
import ShortUrlDetailLink from './ShortUrlDetailLink';
|
||||
import './ShortUrlsRowMenu.scss';
|
||||
|
||||
@@ -29,32 +29,27 @@ const ShortUrlsRowMenu = (
|
||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||
|
||||
return (
|
||||
<ButtonDropdown toggle={toggle} isOpen={isOpen}>
|
||||
<DropdownToggle size="sm" caret outline className="short-urls-row-menu__dropdown-toggle">
|
||||
<FontAwesomeIcon icon={menuIcon} />
|
||||
</DropdownToggle>
|
||||
<DropdownMenu right>
|
||||
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
|
||||
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
||||
</DropdownItem>
|
||||
<DropdownBtnMenu toggle={toggle} isOpen={isOpen}>
|
||||
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
|
||||
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="edit">
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
|
||||
</DropdownItem>
|
||||
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="edit">
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem onClick={toggleQrCode}>
|
||||
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
||||
</DropdownItem>
|
||||
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
|
||||
<DropdownItem onClick={toggleQrCode}>
|
||||
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
||||
</DropdownItem>
|
||||
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
|
||||
|
||||
<DropdownItem divider />
|
||||
<DropdownItem divider />
|
||||
|
||||
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||
</DropdownItem>
|
||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
|
||||
</DropdownMenu>
|
||||
</ButtonDropdown>
|
||||
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||
</DropdownItem>
|
||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
|
||||
</DropdownBtnMenu>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { FC } from 'react';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { DropdownBtn } from '../../../utils/DropdownBtn';
|
||||
import { QrErrorCorrection } from '../../../utils/helpers/qrCodes';
|
||||
|
||||
interface QrErrorCorrectionDropdownProps {
|
||||
errorCorrection: QrErrorCorrection;
|
||||
setErrorCorrection: (errorCorrection: QrErrorCorrection) => void;
|
||||
}
|
||||
|
||||
export const QrErrorCorrectionDropdown: FC<QrErrorCorrectionDropdownProps> = (
|
||||
{ errorCorrection, setErrorCorrection },
|
||||
) => (
|
||||
<DropdownBtn text={`Error correction (${errorCorrection})`}>
|
||||
<DropdownItem active={errorCorrection === 'L'} onClick={() => setErrorCorrection('L')}>
|
||||
<b>L</b>ow
|
||||
</DropdownItem>
|
||||
<DropdownItem active={errorCorrection === 'M'} onClick={() => setErrorCorrection('M')}>
|
||||
<b>M</b>edium
|
||||
</DropdownItem>
|
||||
<DropdownItem active={errorCorrection === 'Q'} onClick={() => setErrorCorrection('Q')}>
|
||||
<b>Q</b>uartile
|
||||
</DropdownItem>
|
||||
<DropdownItem active={errorCorrection === 'H'} onClick={() => setErrorCorrection('H')}>
|
||||
<b>H</b>igh
|
||||
</DropdownItem>
|
||||
</DropdownBtn>
|
||||
);
|
||||
16
src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx
Normal file
16
src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { FC } from 'react';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { DropdownBtn } from '../../../utils/DropdownBtn';
|
||||
import { QrCodeFormat } from '../../../utils/helpers/qrCodes';
|
||||
|
||||
interface QrFormatDropdownProps {
|
||||
format: QrCodeFormat;
|
||||
setFormat: (format: QrCodeFormat) => void;
|
||||
}
|
||||
|
||||
export const QrFormatDropdown: FC<QrFormatDropdownProps> = ({ format, setFormat }) => (
|
||||
<DropdownBtn text={`Format (${format})`}>
|
||||
<DropdownItem active={format === 'png'} onClick={() => setFormat('png')}>PNG</DropdownItem>
|
||||
<DropdownItem active={format === 'svg'} onClick={() => setFormat('svg')}>SVG</DropdownItem>
|
||||
</DropdownBtn>
|
||||
);
|
||||
@@ -5,6 +5,7 @@ import { buildReducer, buildActionCreator } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
|
||||
@@ -24,17 +25,13 @@ 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 & CreateShortUrlFailedAction>({
|
||||
export default buildReducer<ShortUrlCreation, CreateShortUrlAction & ApiErrorAction>({
|
||||
[CREATE_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||
[CREATE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
||||
[CREATE_SHORT_URL]: (_, { result }) => ({ result, saving: false, error: false }),
|
||||
@@ -53,7 +50,7 @@ export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
||||
|
||||
dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result });
|
||||
} catch (e) {
|
||||
dispatch<CreateShortUrlFailedAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
dispatch<ApiErrorAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ProblemDetailsError } from '../../api/types';
|
||||
import { GetState } from '../../container/types';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
|
||||
@@ -24,17 +25,13 @@ export interface DeleteShortUrlAction extends Action<string> {
|
||||
domain?: string | null;
|
||||
}
|
||||
|
||||
interface DeleteShortUrlErrorAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlDeletion = {
|
||||
shortCode: '',
|
||||
loading: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<ShortUrlDeletion, DeleteShortUrlAction & DeleteShortUrlErrorAction>({
|
||||
export default buildReducer<ShortUrlDeletion, DeleteShortUrlAction & ApiErrorAction>({
|
||||
[DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||
[DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
|
||||
[SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
|
||||
@@ -52,7 +49,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: parseApiError(e) });
|
||||
dispatch<ApiErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { GetState } from '../../container/types';
|
||||
import { shortUrlMatches } from '../helpers';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START';
|
||||
@@ -25,16 +26,12 @@ export interface ShortUrlDetailAction extends Action<string> {
|
||||
shortUrl: ShortUrl;
|
||||
}
|
||||
|
||||
export interface ShortUrlDetailFailedAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlDetail = {
|
||||
loading: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<ShortUrlDetail, ShortUrlDetailAction & ShortUrlDetailFailedAction>({
|
||||
export default buildReducer<ShortUrlDetail, ShortUrlDetailAction & ApiErrorAction>({
|
||||
[GET_SHORT_URL_DETAIL_START]: () => ({ loading: true, error: false }),
|
||||
[GET_SHORT_URL_DETAIL_ERROR]: (_, { errorData }) => ({ loading: false, error: true, errorData }),
|
||||
[GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }),
|
||||
@@ -54,6 +51,6 @@ export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder)
|
||||
|
||||
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });
|
||||
} catch (e) {
|
||||
dispatch<ShortUrlDetailFailedAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
|
||||
dispatch<ApiErrorAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { supportsTagsInPatch } from '../../utils/helpers/features';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
|
||||
@@ -25,16 +26,12 @@ export interface ShortUrlEditedAction extends Action<string> {
|
||||
shortUrl: ShortUrl;
|
||||
}
|
||||
|
||||
export interface ShortUrlEditionFailedAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlEdition = {
|
||||
saving: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<ShortUrlEdition, ShortUrlEditedAction & ShortUrlEditionFailedAction>({
|
||||
export default buildReducer<ShortUrlEdition, ShortUrlEditedAction & ApiErrorAction>({
|
||||
[EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||
[EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
||||
[SHORT_URL_EDITED]: (_, { shortUrl }) => ({ shortUrl, saving: false, error: false }),
|
||||
@@ -59,7 +56,7 @@ export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||
|
||||
dispatch<ShortUrlEditedAction>({ shortUrl, type: SHORT_URL_EDITED });
|
||||
} catch (e) {
|
||||
dispatch<ShortUrlEditionFailedAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
dispatch<ApiErrorAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
|
||||
bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ]));
|
||||
|
||||
bottle.serviceFactory('QrCodeModal', () => QrCodeModal);
|
||||
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader', 'ForServerVersion');
|
||||
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
|
||||
|
||||
// Services
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Card, CardHeader, CardBody, Button, Collapse } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTrash as deleteIcon, faPencilAlt as editIcon, faLink, faEye } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FC } from 'react';
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { prettify } from '../utils/helpers/numbers';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { Versions } from '../utils/helpers/version';
|
||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||
import { isServerWithId, SelectedServer } from '../servers/data';
|
||||
import TagBullet from './helpers/TagBullet';
|
||||
@@ -20,17 +19,24 @@ export interface TagCardProps {
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
const isTruncated = (el: HTMLElement | undefined): boolean => !!el && el.scrollWidth > el.clientWidth;
|
||||
|
||||
const TagCard = (
|
||||
DeleteTagConfirmModal: FC<TagModalProps>,
|
||||
EditTagModal: FC<TagModalProps>,
|
||||
ForServerVersion: FC<Versions>,
|
||||
colorGenerator: ColorGenerator,
|
||||
) => ({ tag, tagStats, selectedServer, displayed, toggle }: TagCardProps) => {
|
||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||
|
||||
const [ hasTitle,, displayTitle ] = useToggle();
|
||||
const titleRef = useRef<HTMLElement>();
|
||||
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
||||
const shortUrlsLink = `/server/${serverId}/list-short-urls/1?tag=${tag}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (isTruncated(titleRef.current)) {
|
||||
displayTitle();
|
||||
}
|
||||
}, [ titleRef.current ]);
|
||||
|
||||
return (
|
||||
<Card className="tag-card">
|
||||
@@ -41,14 +47,15 @@ const TagCard = (
|
||||
<Button color="link" size="sm" className="tag-card__btn" onClick={toggleEdit}>
|
||||
<FontAwesomeIcon icon={editIcon} />
|
||||
</Button>
|
||||
<h5 className="tag-card__tag-title text-ellipsis">
|
||||
<h5
|
||||
className="tag-card__tag-title text-ellipsis"
|
||||
title={hasTitle ? tag : undefined}
|
||||
ref={(el) => {
|
||||
titleRef.current = el ?? undefined;
|
||||
}}
|
||||
>
|
||||
<TagBullet tag={tag} colorGenerator={colorGenerator} />
|
||||
<ForServerVersion minVersion="2.2.0">
|
||||
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span>
|
||||
</ForServerVersion>
|
||||
<ForServerVersion maxVersion="2.1.*">
|
||||
<Link to={shortUrlsLink}>{tag}</Link>
|
||||
</ForServerVersion>
|
||||
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span>
|
||||
</h5>
|
||||
</CardHeader>
|
||||
|
||||
@@ -56,7 +63,7 @@ const TagCard = (
|
||||
<Collapse isOpen={displayed}>
|
||||
<CardBody className="tag-card__body">
|
||||
<Link
|
||||
to={shortUrlsLink}
|
||||
to={`/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag)}`}
|
||||
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center mb-1"
|
||||
>
|
||||
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="mr-2" />Short URLs</span>
|
||||
|
||||
33
src/tags/TagsCards.tsx
Normal file
33
src/tags/TagsCards.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { splitEvery } from 'ramda';
|
||||
import { Row } from 'reactstrap';
|
||||
import { TagCardProps } from './TagCard';
|
||||
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
||||
|
||||
const { ceil } = Math;
|
||||
const TAGS_GROUPS_AMOUNT = 4;
|
||||
|
||||
export const TagsCards = (TagCard: FC<TagCardProps>): FC<TagsListChildrenProps> => ({ tagsList, selectedServer }) => {
|
||||
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
|
||||
const tagsCount = tagsList.filteredTags.length;
|
||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
||||
|
||||
return (
|
||||
<Row>
|
||||
{tagsGroups.map((group, index) => (
|
||||
<div key={index} className="col-md-6 col-xl-3">
|
||||
{group.map((tag) => (
|
||||
<TagCard
|
||||
key={tag}
|
||||
tag={tag}
|
||||
tagStats={tagsList.stats[tag]}
|
||||
selectedServer={selectedServer}
|
||||
displayed={displayedTag === tag}
|
||||
toggle={() => setDisplayedTag(displayedTag !== tag ? tag : undefined)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { splitEvery } from 'ramda';
|
||||
import { Row } from 'reactstrap';
|
||||
import Message from '../utils/Message';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
@@ -7,33 +7,33 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { Result } from '../utils/Result';
|
||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import { Settings, TagsMode } from '../settings/reducers/settings';
|
||||
import { TagsList as TagsListState } from './reducers/tagsList';
|
||||
import { TagCardProps } from './TagCard';
|
||||
|
||||
const { ceil } = Math;
|
||||
const TAGS_GROUPS_AMOUNT = 4;
|
||||
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
||||
import { TagsModeDropdown } from './TagsModeDropdown';
|
||||
|
||||
export interface TagsListProps {
|
||||
filterTags: (searchTerm: string) => void;
|
||||
forceListTags: Function;
|
||||
tagsList: TagsListState;
|
||||
selectedServer: SelectedServer;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
||||
{ filterTags, forceListTags, tagsList, selectedServer }: TagsListProps,
|
||||
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsListChildrenProps>) => boundToMercureHub((
|
||||
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
|
||||
) => {
|
||||
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
|
||||
const [ mode, setMode ] = useState<TagsMode>(settings.ui?.tagsMode ?? 'cards');
|
||||
|
||||
useEffect(() => {
|
||||
forceListTags();
|
||||
}, []);
|
||||
|
||||
const renderContent = () => {
|
||||
if (tagsList.loading) {
|
||||
return <Message loading />;
|
||||
}
|
||||
if (tagsList.loading) {
|
||||
return <Message loading />;
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (tagsList.error) {
|
||||
return (
|
||||
<Result type="error">
|
||||
@@ -42,40 +42,26 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
||||
);
|
||||
}
|
||||
|
||||
const tagsCount = tagsList.filteredTags.length;
|
||||
|
||||
if (tagsCount < 1) {
|
||||
if (tagsList.filteredTags.length < 1) {
|
||||
return <Message>No tags found</Message>;
|
||||
}
|
||||
|
||||
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) => (
|
||||
<TagCard
|
||||
key={tag}
|
||||
tag={tag}
|
||||
tagStats={tagsList.stats[tag]}
|
||||
selectedServer={selectedServer}
|
||||
displayed={displayedTag === tag}
|
||||
toggle={() => setDisplayedTag(displayedTag !== tag ? tag : undefined)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return mode === 'cards'
|
||||
? <TagsCards tagsList={tagsList} selectedServer={selectedServer} />
|
||||
: <TagsTable tagsList={tagsList} selectedServer={selectedServer} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!tagsList.loading && <SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />}
|
||||
<SearchField className="mb-3" onChange={filterTags} />
|
||||
<Row className="mb-3">
|
||||
<div className="col-lg-6 offset-lg-6">
|
||||
<TagsModeDropdown mode={mode} onChange={setMode} />
|
||||
</div>
|
||||
</Row>
|
||||
{renderContent()}
|
||||
</>
|
||||
);
|
||||
}, () => [ Topics.visits() ]);
|
||||
}, () => [ Topics.visits ]);
|
||||
|
||||
export default TagsList;
|
||||
|
||||
23
src/tags/TagsModeDropdown.tsx
Normal file
23
src/tags/TagsModeDropdown.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { FC } from 'react';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faBars as listIcon, faThLarge as cardsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { DropdownBtn } from '../utils/DropdownBtn';
|
||||
import { TagsMode } from '../settings/reducers/settings';
|
||||
|
||||
interface TagsModeDropdownProps {
|
||||
mode: TagsMode;
|
||||
onChange: (newMode: TagsMode) => void;
|
||||
renderTitle?: (mode: TagsMode) => string;
|
||||
}
|
||||
|
||||
export const TagsModeDropdown: FC<TagsModeDropdownProps> = ({ mode, onChange, renderTitle }) => (
|
||||
<DropdownBtn text={renderTitle?.(mode) ?? `Display mode: ${mode}`}>
|
||||
<DropdownItem active={mode === 'cards'} onClick={() => onChange('cards')}>
|
||||
<FontAwesomeIcon icon={cardsIcon} fixedWidth className="mr-1" /> Cards
|
||||
</DropdownItem>
|
||||
<DropdownItem active={mode === 'list'} onClick={() => onChange('list')}>
|
||||
<FontAwesomeIcon icon={listIcon} fixedWidth className="mr-1" /> List
|
||||
</DropdownItem>
|
||||
</DropdownBtn>
|
||||
);
|
||||
65
src/tags/TagsTable.tsx
Normal file
65
src/tags/TagsTable.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
import { splitEvery } from 'ramda';
|
||||
import { RouteChildrenProps } from 'react-router';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||
import SimplePaginator from '../common/SimplePaginator';
|
||||
import { useQueryState } from '../utils/helpers/hooks';
|
||||
import { parseQuery } from '../utils/helpers/query';
|
||||
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
||||
import { TagsTableRowProps } from './TagsTableRow';
|
||||
|
||||
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
|
||||
|
||||
export const TagsTable = (colorGenerator: ColorGenerator, TagsTableRow: FC<TagsTableRowProps>) => (
|
||||
{ tagsList, selectedServer, location }: TagsListChildrenProps & RouteChildrenProps,
|
||||
) => {
|
||||
const isFirstLoad = useRef(true);
|
||||
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(location.search);
|
||||
const [ page, setPage ] = useQueryState<number>('page', Number(pageFromQuery));
|
||||
const sortedTags = tagsList.filteredTags; // TODO Support sorting tags
|
||||
const pages = splitEvery(TAGS_PER_PAGE, sortedTags);
|
||||
const showPaginator = pages.length > 1;
|
||||
const currentPage = pages[page - 1] ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
!isFirstLoad.current && setPage(1);
|
||||
isFirstLoad.current = false;
|
||||
}, [ tagsList.filteredTags ]);
|
||||
useEffect(() => {
|
||||
scrollTo(0, 0);
|
||||
}, [ page ]);
|
||||
|
||||
return (
|
||||
<SimpleCard key={page} bodyClassName={showPaginator ? 'pb-1' : ''}>
|
||||
<table className="table table-hover mb-0">
|
||||
<thead className="responsive-table__header">
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th className="text-lg-right">Short URLs</th>
|
||||
<th className="text-lg-right">Visits</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentPage.length === 0 && <tr><td colSpan={4} className="text-center">No results found</td></tr>}
|
||||
{currentPage.map((tag) => (
|
||||
<TagsTableRow
|
||||
key={tag}
|
||||
tag={tag}
|
||||
tagStats={tagsList.stats[tag]}
|
||||
selectedServer={selectedServer}
|
||||
colorGenerator={colorGenerator}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{showPaginator && (
|
||||
<div className="sticky-card-paginator">
|
||||
<SimplePaginator pagesCount={pages.length} currentPage={page} setCurrentPage={setPage} />
|
||||
</div>
|
||||
)}
|
||||
</SimpleCard>
|
||||
);
|
||||
};
|
||||
59
src/tags/TagsTableRow.tsx
Normal file
59
src/tags/TagsTableRow.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTrash as deleteIcon, faPencilAlt as editIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { getServerId, SelectedServer } from '../servers/data';
|
||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||
import { prettify } from '../utils/helpers/numbers';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
|
||||
import TagBullet from './helpers/TagBullet';
|
||||
import { TagModalProps, TagStats } from './data';
|
||||
|
||||
export interface TagsTableRowProps {
|
||||
tag: string;
|
||||
tagStats?: TagStats;
|
||||
selectedServer: SelectedServer;
|
||||
colorGenerator: ColorGenerator;
|
||||
}
|
||||
|
||||
export const TagsTableRow = (DeleteTagConfirmModal: FC<TagModalProps>, EditTagModal: FC<TagModalProps>) => (
|
||||
{ tag, tagStats, colorGenerator, selectedServer }: TagsTableRowProps,
|
||||
) => {
|
||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||
const [ isDropdownOpen, toggleDropdown ] = useToggle();
|
||||
const serverId = getServerId(selectedServer);
|
||||
|
||||
return (
|
||||
<tr className="responsive-table__row">
|
||||
<th className="responsive-table__cell" data-th="Tag">
|
||||
<TagBullet tag={tag} colorGenerator={colorGenerator} /> {tag}
|
||||
</th>
|
||||
<td className="responsive-table__cell text-lg-right" data-th="Short URLs">
|
||||
<Link to={`/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag)}`}>
|
||||
{prettify(tagStats?.shortUrlsCount ?? 0)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="responsive-table__cell text-lg-right" data-th="Visits">
|
||||
<Link to={`/server/${serverId}/tag/${tag}/visits`}>
|
||||
{prettify(tagStats?.visitsCount ?? 0)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="responsive-table__cell text-lg-right">
|
||||
<DropdownBtnMenu toggle={toggleDropdown} isOpen={isDropdownOpen}>
|
||||
<DropdownItem onClick={toggleEdit}>
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth className="mr-1" /> Edit
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={toggleDelete}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth className="mr-1" /> Delete
|
||||
</DropdownItem>
|
||||
</DropdownBtnMenu>
|
||||
</td>
|
||||
|
||||
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
||||
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
7
src/tags/data/TagsListChildrenProps.ts
Normal file
7
src/tags/data/TagsListChildrenProps.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { TagsList as TagsListState } from '../reducers/tagsList';
|
||||
import { SelectedServer } from '../../servers/data';
|
||||
|
||||
export interface TagsListChildrenProps {
|
||||
tagsList: TagsListState;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
||||
import { Button, Input, Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
||||
import { ChromePicker } from 'react-color';
|
||||
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
@@ -25,10 +25,12 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
||||
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)
|
||||
.catch(() => {}));
|
||||
const saveTag = handleEventPreventingDefault(
|
||||
async () => editTag(tag, newTagName, color)
|
||||
.then(() => tagEdited(tag, newTagName, color))
|
||||
.then(toggle)
|
||||
.catch(() => {}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={hideColorPicker}>
|
||||
@@ -47,13 +49,11 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
||||
<Popover isOpen={showColorPicker} toggle={toggleColorPicker} target="colorPickerBtn" placement="right">
|
||||
<ChromePicker color={color} disableAlpha onChange={({ hex }) => setColor(hex)} />
|
||||
</Popover>
|
||||
<input
|
||||
type="text"
|
||||
<Input
|
||||
value={newTagName}
|
||||
placeholder="Tag"
|
||||
required
|
||||
className="form-control"
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
onChange={({ target }) => setNewTagName(target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -64,8 +64,8 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={editing}>{editing ? 'Saving...' : 'Save'}</button>
|
||||
<Button type="button" color="link" onClick={toggle}>Cancel</Button>
|
||||
<Button color="primary" disabled={editing}>{editing ? 'Saving...' : 'Save'}</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { FC } from 'react';
|
||||
import { FC, MouseEventHandler } from 'react';
|
||||
import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||
import './Tag.scss';
|
||||
|
||||
interface TagProps {
|
||||
colorGenerator: ColorGenerator;
|
||||
text: string;
|
||||
className?: string;
|
||||
clearable?: boolean;
|
||||
onClick?: () => void;
|
||||
onClose?: () => void;
|
||||
onClick?: MouseEventHandler;
|
||||
onClose?: MouseEventHandler;
|
||||
}
|
||||
|
||||
const Tag: FC<TagProps> = ({ text, children, clearable, colorGenerator, onClick, onClose }) => (
|
||||
const Tag: FC<TagProps> = ({ text, children, clearable, className = '', colorGenerator, onClick, onClose }) => (
|
||||
<span
|
||||
className="badge tag"
|
||||
className={`badge tag ${className}`}
|
||||
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
@import '../../utils/base';
|
||||
|
||||
.react-autosuggest__suggestions-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.react-autosuggest__suggestion {
|
||||
margin-left: -6px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.react-autosuggest__suggestion--highlighted {
|
||||
background-color: $lightGrey;
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ChangeEvent, useEffect } from 'react';
|
||||
import TagsInput, { RenderInputProps, RenderTagProps } from 'react-tagsinput';
|
||||
import Autosuggest, { ChangeEvent as AutoChangeEvent, SuggestionSelectedEventData } from 'react-autosuggest';
|
||||
import { useEffect } from 'react';
|
||||
import ReactTags, { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete';
|
||||
import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||
import { Settings } from '../../settings/reducers/settings';
|
||||
import { TagsList } from '../reducers/tagsList';
|
||||
import TagBullet from './TagBullet';
|
||||
import './TagsSelector.scss';
|
||||
import Tag from './Tag';
|
||||
|
||||
export interface TagsSelectorProps {
|
||||
tags: string[];
|
||||
selectedTags: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
@@ -15,67 +15,55 @@ export interface TagsSelectorProps {
|
||||
interface TagsSelectorConnectProps extends TagsSelectorProps {
|
||||
listTags: Function;
|
||||
tagsList: TagsList;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
const toComponentTag = (tag: string) => ({ id: tag, name: tag });
|
||||
|
||||
const TagsSelector = (colorGenerator: ColorGenerator) => (
|
||||
{ tags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }: TagsSelectorConnectProps,
|
||||
{ selectedTags, onChange, placeholder, listTags, tagsList, settings }: TagsSelectorConnectProps,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
listTags();
|
||||
}, []);
|
||||
|
||||
const renderTag = (
|
||||
{ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }: RenderTagProps<string>,
|
||||
) => (
|
||||
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}>
|
||||
{getTagDisplayValue(tag)}
|
||||
{!disabled && <span className={classNameRemove} onClick={() => onRemove(key)} />}
|
||||
</span>
|
||||
const searchMode = settings.shortUrlCreation?.tagFilteringMode ?? 'startsWith';
|
||||
const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) =>
|
||||
<Tag colorGenerator={colorGenerator} text={tag.name} clearable className="react-tags__tag" onClose={onDelete} />;
|
||||
const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => (
|
||||
<>
|
||||
<TagBullet tag={`${item.name}`} colorGenerator={colorGenerator} />
|
||||
{item.name}
|
||||
</>
|
||||
);
|
||||
const renderAutocompleteInput = (data: RenderInputProps<string>) => {
|
||||
const { addTag, ...otherProps } = data;
|
||||
const handleOnChange = (e: ChangeEvent<HTMLInputElement>, { method }: AutoChangeEvent) => {
|
||||
method === 'enter' ? e.preventDefault() : otherProps.onChange(e);
|
||||
};
|
||||
|
||||
const inputValue = otherProps.value?.trim().toLowerCase() ?? '';
|
||||
const suggestions = tagsList.tags.filter((tag) => tag.startsWith(inputValue));
|
||||
|
||||
return (
|
||||
<Autosuggest
|
||||
ref={otherProps.ref}
|
||||
suggestions={suggestions}
|
||||
inputProps={{ ...otherProps, onChange: handleOnChange }}
|
||||
highlightFirstSuggestion
|
||||
shouldRenderSuggestions={(value: string) => value.trim().length > 0}
|
||||
getSuggestionValue={(suggestion) => suggestion}
|
||||
renderSuggestion={(suggestion) => (
|
||||
<>
|
||||
<TagBullet tag={suggestion} colorGenerator={colorGenerator} />
|
||||
{suggestion}
|
||||
</>
|
||||
)}
|
||||
onSuggestionsFetchRequested={noop}
|
||||
onSuggestionsClearRequested={noop}
|
||||
onSuggestionSelected={(_, { suggestion }: SuggestionSelectedEventData<string>) => {
|
||||
addTag(suggestion);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TagsInput
|
||||
value={tags}
|
||||
inputProps={{ placeholder }}
|
||||
onlyUnique
|
||||
renderTag={renderTag}
|
||||
renderInput={renderAutocompleteInput}
|
||||
// FIXME Workaround to be able to add tags on Android
|
||||
<ReactTags
|
||||
tags={selectedTags.map(toComponentTag)}
|
||||
tagComponent={ReactTagsTag}
|
||||
suggestions={tagsList.tags.filter((tag) => !selectedTags.includes(tag)).map(toComponentTag)}
|
||||
suggestionComponent={ReactTagsSuggestion}
|
||||
allowNew
|
||||
addOnBlur
|
||||
onChange={onChange}
|
||||
placeholderText={placeholder ?? 'Add tags to the URL'}
|
||||
minQueryLength={1}
|
||||
delimiters={[ 'Enter', 'Tab', ',' ]}
|
||||
suggestionsTransform={
|
||||
searchMode === 'includes'
|
||||
? (query, suggestions) => suggestions.filter(({ name }) => name.includes(query))
|
||||
: undefined
|
||||
}
|
||||
onDelete={(removedTagIndex) => {
|
||||
const tagsCopy = [ ...selectedTags ];
|
||||
|
||||
tagsCopy.splice(removedTagIndex, 1);
|
||||
onChange(tagsCopy);
|
||||
}}
|
||||
onAddition={({ name: newTag }) => onChange(
|
||||
// * Avoid duplicated tags (thanks to the Set),
|
||||
// * Split any of the new tags by comma, allowing to paste multiple comma-separated tags at once.
|
||||
[ ...new Set([ ...selectedTags, ...newTag.toLowerCase().split(',') ]) ],
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { GetState } from '../../container/types';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
||||
@@ -22,16 +23,12 @@ 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<TagDeletion, DeleteTagFailedAction>({
|
||||
export default buildReducer<TagDeletion, ApiErrorAction>({
|
||||
[DELETE_TAG_START]: () => ({ deleting: true, error: false }),
|
||||
[DELETE_TAG_ERROR]: (_, { errorData }) => ({ deleting: false, error: true, errorData }),
|
||||
[DELETE_TAG]: () => ({ deleting: false, error: false }),
|
||||
@@ -48,7 +45,7 @@ export const deleteTag = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag:
|
||||
await deleteTags([ tag ]);
|
||||
dispatch({ type: DELETE_TAG });
|
||||
} catch (e) {
|
||||
dispatch<DeleteTagFailedAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
|
||||
dispatch<ApiErrorAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
||||
@@ -29,10 +30,6 @@ export interface EditTagAction extends Action<string> {
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface EditTagFailedAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: TagEdition = {
|
||||
oldName: '',
|
||||
newName: '',
|
||||
@@ -40,7 +37,7 @@ const initialState: TagEdition = {
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<TagEdition, EditTagAction & EditTagFailedAction>({
|
||||
export default buildReducer<TagEdition, EditTagAction & ApiErrorAction>({
|
||||
[EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }),
|
||||
[EDIT_TAG_ERROR]: (state, { errorData }) => ({ ...state, editing: false, error: true, errorData }),
|
||||
[EDIT_TAG]: (_, action) => ({
|
||||
@@ -63,7 +60,7 @@ export const editTag = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGener
|
||||
colorGenerator.setColorForKey(newName, color);
|
||||
dispatch({ type: EDIT_TAG, oldName, newName });
|
||||
} catch (e) {
|
||||
dispatch<EditTagFailedAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
|
||||
dispatch<ApiErrorAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde
|
||||
import { CreateVisit, Stats } from '../../visits/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { TagStats } from '../data';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { DeleteTagAction, TAG_DELETED } from './tagDelete';
|
||||
import { EditTagAction, TAG_EDITED } from './tagEdit';
|
||||
|
||||
@@ -34,20 +35,16 @@ interface ListTagsAction extends Action<string> {
|
||||
stats: TagsStatsMap;
|
||||
}
|
||||
|
||||
interface ListTagsFailedAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
interface FilterTagsAction extends Action<string> {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
type ListTagsCombinedAction = ListTagsAction
|
||||
type TagsCombinedAction = ListTagsAction
|
||||
& DeleteTagAction
|
||||
& CreateVisitsAction
|
||||
& EditTagAction
|
||||
& FilterTagsAction
|
||||
& ListTagsFailedAction;
|
||||
& ApiErrorAction;
|
||||
|
||||
const initialState = {
|
||||
tags: [],
|
||||
@@ -83,7 +80,7 @@ const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => O
|
||||
}, {}),
|
||||
);
|
||||
|
||||
export default buildReducer<TagsList, ListTagsCombinedAction>({
|
||||
export default buildReducer<TagsList, TagsCombinedAction>({
|
||||
[LIST_TAGS_START]: () => ({ ...initialState, loading: true }),
|
||||
[LIST_TAGS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
||||
[LIST_TAGS]: (_, { tags, stats }) => ({ ...initialState, stats, tags, filteredTags: tags }),
|
||||
@@ -130,7 +127,7 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t
|
||||
|
||||
dispatch<ListTagsAction>({ tags, stats: processedStats, type: LIST_TAGS });
|
||||
} catch (e) {
|
||||
dispatch<ListTagsFailedAction>({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) });
|
||||
dispatch<ApiErrorAction>({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Bottle, { IContainer } from 'bottlejs';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import TagsSelector from '../helpers/TagsSelector';
|
||||
import TagCard from '../TagCard';
|
||||
import DeleteTagConfirmModal from '../helpers/DeleteTagConfirmModal';
|
||||
@@ -8,20 +9,16 @@ import { filterTags, listTags } from '../reducers/tagsList';
|
||||
import { deleteTag, tagDeleted } from '../reducers/tagDelete';
|
||||
import { editTag, tagEdited } from '../reducers/tagEdit';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { TagsCards } from '../TagsCards';
|
||||
import { TagsTable } from '../TagsTable';
|
||||
import { TagsTableRow } from '../TagsTableRow';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
|
||||
bottle.decorator('TagsSelector', connect([ 'tagsList' ], [ 'listTags' ]));
|
||||
bottle.decorator('TagsSelector', connect([ 'tagsList', 'settings' ], [ 'listTags' ]));
|
||||
|
||||
bottle.serviceFactory(
|
||||
'TagCard',
|
||||
TagCard,
|
||||
'DeleteTagConfirmModal',
|
||||
'EditTagModal',
|
||||
'ForServerVersion',
|
||||
'ColorGenerator',
|
||||
);
|
||||
bottle.serviceFactory('TagCard', TagCard, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
|
||||
|
||||
bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal);
|
||||
bottle.decorator('DeleteTagConfirmModal', connect([ 'tagDelete' ], [ 'deleteTag', 'tagDeleted' ]));
|
||||
@@ -29,9 +26,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator');
|
||||
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
|
||||
|
||||
bottle.serviceFactory('TagsList', TagsList, 'TagCard');
|
||||
bottle.serviceFactory('TagsCards', TagsCards, 'TagCard');
|
||||
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal');
|
||||
|
||||
bottle.serviceFactory('TagsTable', TagsTable, 'ColorGenerator', 'TagsTableRow');
|
||||
bottle.decorator('TagsTable', withRouter);
|
||||
|
||||
bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable');
|
||||
bottle.decorator('TagsList', connect(
|
||||
[ 'tagsList', 'selectedServer', 'mercureInfo' ],
|
||||
[ 'tagsList', 'selectedServer', 'mercureInfo', 'settings' ],
|
||||
[ 'forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo' ],
|
||||
));
|
||||
|
||||
|
||||
@@ -1,33 +1,12 @@
|
||||
import { useRef } from 'react';
|
||||
import { isNil, dissoc } from 'ramda';
|
||||
import { isNil } from 'ramda';
|
||||
import DatePicker, { ReactDatePickerProps } from 'react-datepicker';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCalendarAlt as calendarIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import './DateInput.scss';
|
||||
|
||||
interface DatePropsInterface {
|
||||
endDate?: moment.Moment | null;
|
||||
maxDate?: moment.Moment | null;
|
||||
minDate?: moment.Moment | null;
|
||||
selected?: moment.Moment | null;
|
||||
startDate?: moment.Moment | null;
|
||||
onChange?: (date: moment.Moment | null) => void;
|
||||
}
|
||||
|
||||
export type DateInputProps = DatePropsInterface & Omit<ReactDatePickerProps, keyof DatePropsInterface>;
|
||||
|
||||
const transformProps = (props: DateInputProps): ReactDatePickerProps => ({
|
||||
// @ts-expect-error The DatePicker type definition is wrong. It has a ref prop
|
||||
...dissoc('ref', props),
|
||||
endDate: props.endDate?.toDate(),
|
||||
maxDate: props.maxDate?.toDate(),
|
||||
minDate: props.minDate?.toDate(),
|
||||
selected: props.selected?.toDate(),
|
||||
startDate: props.startDate?.toDate(),
|
||||
onChange: (date: Date | null) => props.onChange?.(date && moment(date)),
|
||||
});
|
||||
export type DateInputProps = ReactDatePickerProps;
|
||||
|
||||
const DateInput = (props: DateInputProps) => {
|
||||
const { className, isClearable, selected } = props;
|
||||
@@ -37,7 +16,7 @@ const DateInput = (props: DateInputProps) => {
|
||||
return (
|
||||
<div className="date-input-container">
|
||||
<DatePicker
|
||||
{...transformProps(props)}
|
||||
{...props}
|
||||
dateFormat="yyyy-MM-dd"
|
||||
className={classNames('date-input-container__input form-control', className)}
|
||||
// @ts-expect-error The DatePicker type definition is wrong. It has a ref prop
|
||||
|
||||
@@ -9,18 +9,20 @@ export interface DropdownBtnProps {
|
||||
className?: string;
|
||||
dropdownClassName?: string;
|
||||
right?: boolean;
|
||||
minWidth?: number;
|
||||
}
|
||||
|
||||
export const DropdownBtn: FC<DropdownBtnProps> = (
|
||||
{ text, disabled = false, className = '', children, dropdownClassName, right = false },
|
||||
{ text, disabled = false, className = '', children, dropdownClassName, right = false, minWidth },
|
||||
) => {
|
||||
const [ isOpen, toggle ] = useToggle();
|
||||
const toggleClasses = `dropdown-btn__toggle btn-block ${className}`;
|
||||
const style = { minWidth: minWidth && `${minWidth}px` };
|
||||
|
||||
return (
|
||||
<Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled} className={dropdownClassName}>
|
||||
<DropdownToggle caret className={toggleClasses} color="primary">{text}</DropdownToggle>
|
||||
<DropdownMenu className="w-100" right={right}>{children}</DropdownMenu>
|
||||
<DropdownMenu className="w-100" right={right} style={style}>{children}</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
3
src/utils/DropdownBtnMenu.scss
Normal file
3
src/utils/DropdownBtnMenu.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.dropdown-btn-menu__dropdown-toggle:after {
|
||||
display: none !important;
|
||||
}
|
||||
20
src/utils/DropdownBtnMenu.tsx
Normal file
20
src/utils/DropdownBtnMenu.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { FC } from 'react';
|
||||
import { ButtonDropdown, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEllipsisV as menuIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import './DropdownBtnMenu.scss';
|
||||
|
||||
export interface DropdownBtnMenuProps {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
right?: boolean;
|
||||
}
|
||||
|
||||
export const DropdownBtnMenu: FC<DropdownBtnMenuProps> = ({ isOpen, toggle, children, right = true }) => (
|
||||
<ButtonDropdown toggle={toggle} isOpen={isOpen}>
|
||||
<DropdownToggle size="sm" caret outline className="dropdown-btn-menu__dropdown-toggle">
|
||||
<FontAwesomeIcon icon={menuIcon} />
|
||||
</DropdownToggle>
|
||||
<DropdownMenu right={right}>{children}</DropdownMenu>
|
||||
</ButtonDropdown>
|
||||
);
|
||||
@@ -1,29 +1,37 @@
|
||||
import { FC } from 'react';
|
||||
import { FC, useRef } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { InputType } from 'reactstrap/lib/Input';
|
||||
|
||||
interface FormGroupContainerProps {
|
||||
export interface FormGroupContainerProps {
|
||||
value: string;
|
||||
onChange: (newValue: string) => void;
|
||||
id?: string;
|
||||
type?: InputType;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
export const FormGroupContainer: FC<FormGroupContainerProps> = (
|
||||
{ children, value, onChange, id = uuid(), type = 'text', required = true },
|
||||
) => (
|
||||
<div className="form-group">
|
||||
<label htmlFor={id} className="create-server__label">
|
||||
{children}:
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
type={type}
|
||||
id={id}
|
||||
value={value}
|
||||
required={required}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
{ children, value, onChange, id, type, required, placeholder, className, labelClassName },
|
||||
) => {
|
||||
const forId = useRef<string>(id ?? uuid());
|
||||
|
||||
return (
|
||||
<div className={`form-group ${className ?? ''}`}>
|
||||
<label htmlFor={forId.current} className={labelClassName ?? ''}>
|
||||
{children}:
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
type={type ?? 'text'}
|
||||
id={forId.current}
|
||||
value={value}
|
||||
required={required ?? true}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
26
src/utils/InfoTooltip.tsx
Normal file
26
src/utils/InfoTooltip.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { FC, useRef } from 'react';
|
||||
import * as Popper from 'popper.js';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
|
||||
interface InfoTooltipProps {
|
||||
className?: string;
|
||||
placement: Popper.Placement;
|
||||
}
|
||||
|
||||
export const InfoTooltip: FC<InfoTooltipProps> = ({ className = '', placement, children }) => {
|
||||
const ref = useRef<HTMLSpanElement | null>();
|
||||
const refCallback = (el: HTMLSpanElement) => {
|
||||
ref.current = el;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={className} ref={refCallback}>
|
||||
<FontAwesomeIcon icon={infoIcon} />
|
||||
</span>
|
||||
<UncontrolledTooltip target={(() => ref.current) as any} placement={placement}>{children}</UncontrolledTooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -10,14 +10,11 @@ let timer: NodeJS.Timeout | null;
|
||||
interface SearchFieldProps {
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
large?: boolean;
|
||||
noBorder?: boolean;
|
||||
}
|
||||
|
||||
const SearchField = (
|
||||
{ onChange, className, placeholder = 'Search...', large = true, noBorder = false }: SearchFieldProps,
|
||||
) => {
|
||||
const SearchField = ({ onChange, className, large = true, noBorder = false }: SearchFieldProps) => {
|
||||
const [ searchTerm, setSearchTerm ] = useState('');
|
||||
|
||||
const resetTimer = () => {
|
||||
@@ -43,7 +40,7 @@ const SearchField = (
|
||||
'form-control-lg': large,
|
||||
'search-field__input--no-border': noBorder,
|
||||
})}
|
||||
placeholder={placeholder}
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => searchTermChanged(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.short-urls-paginator {
|
||||
.sticky-card-paginator {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background-color: var(--primary-color-alfa);
|
||||
18
src/utils/Time.tsx
Normal file
18
src/utils/Time.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { parseISO, format as formatDate, getUnixTime, formatDistance } from 'date-fns';
|
||||
import { isDateObject } from './helpers/date';
|
||||
|
||||
export interface DateProps {
|
||||
date: Date | string;
|
||||
format?: string;
|
||||
relative?: boolean;
|
||||
}
|
||||
|
||||
export const Time = ({ date, format = 'yyyy-MM-dd HH:mm', relative = false }: DateProps) => {
|
||||
const dateObject = isDateObject(date) ? date : parseISO(date);
|
||||
|
||||
return (
|
||||
<time dateTime={`${getUnixTime(dateObject)}000`}>
|
||||
{relative ? `${formatDistance(new Date(), dateObject)} ago` : formatDate(dateObject, format)}
|
||||
</time>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,9 @@
|
||||
import moment from 'moment';
|
||||
import DateInput from '../DateInput';
|
||||
import { DateRange } from './types';
|
||||
|
||||
interface DateRangeRowProps extends DateRange {
|
||||
onStartDateChange: (date: moment.Moment | null) => void;
|
||||
onEndDateChange: (date: moment.Moment | null) => void;
|
||||
onStartDateChange: (date: Date | null) => void;
|
||||
onEndDateChange: (date: Date | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import moment from 'moment';
|
||||
import { subDays, startOfDay, endOfDay } from 'date-fns';
|
||||
import { filter, isEmpty } from 'ramda';
|
||||
import { formatInternational } from '../../helpers/date';
|
||||
|
||||
export interface DateRange {
|
||||
startDate?: moment.Moment | null;
|
||||
endDate?: moment.Moment | null;
|
||||
startDate?: Date | null;
|
||||
endDate?: Date | null;
|
||||
}
|
||||
|
||||
export type DateInterval = 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180days' | 'last365Days';
|
||||
@@ -54,6 +54,9 @@ export const rangeOrIntervalToString = (range?: DateRange | DateInterval): strin
|
||||
return INTERVAL_TO_STRING_MAP[range];
|
||||
};
|
||||
|
||||
const startOfDaysAgo = (daysAgo: number) => startOfDay(subDays(new Date(), daysAgo));
|
||||
const endingToday = (startDate: Date): DateRange => ({ startDate, endDate: endOfDay(new Date()) });
|
||||
|
||||
export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
|
||||
if (!dateInterval) {
|
||||
return {};
|
||||
@@ -61,21 +64,19 @@ export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
|
||||
|
||||
switch (dateInterval) {
|
||||
case 'today':
|
||||
return { startDate: moment().startOf('day'), endDate: moment() };
|
||||
return endingToday(startOfDay(new Date()));
|
||||
case 'yesterday':
|
||||
const yesterday = moment().subtract(1, 'day'); // eslint-disable-line no-case-declarations
|
||||
|
||||
return { startDate: yesterday.startOf('day'), endDate: yesterday.endOf('day') };
|
||||
return { startDate: startOfDaysAgo(1), endDate: endOfDay(subDays(new Date(), 1)) };
|
||||
case 'last7Days':
|
||||
return { startDate: moment().subtract(7, 'days').startOf('day'), endDate: moment() };
|
||||
return endingToday(startOfDaysAgo(7));
|
||||
case 'last30Days':
|
||||
return { startDate: moment().subtract(30, 'days').startOf('day'), endDate: moment() };
|
||||
return endingToday(startOfDaysAgo(30));
|
||||
case 'last90Days':
|
||||
return { startDate: moment().subtract(90, 'days').startOf('day'), endDate: moment() };
|
||||
return endingToday(startOfDaysAgo(90));
|
||||
case 'last180days':
|
||||
return { startDate: moment().subtract(180, 'days').startOf('day'), endDate: moment() };
|
||||
return endingToday(startOfDaysAgo(180));
|
||||
case 'last365Days':
|
||||
return { startDate: moment().subtract(365, 'days').startOf('day'), endDate: moment() };
|
||||
return endingToday(startOfDaysAgo(365));
|
||||
}
|
||||
|
||||
return {};
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
import { ChangeEvent, FC } from 'react';
|
||||
import { ChartData, ChartTooltipItem } from 'chart.js';
|
||||
import { ActiveElement, ChartEvent, ChartType, TooltipItem } from 'chart.js';
|
||||
import { prettify } from './numbers';
|
||||
|
||||
export const pointerOnHover = ({ target }: ChangeEvent<HTMLElement>, chartElement: FC[]) => {
|
||||
target.style.cursor = chartElement[0] ? 'pointer' : 'default';
|
||||
export const pointerOnHover = ({ native }: ChartEvent, [ firstElement ]: ActiveElement[]) => {
|
||||
if (!native?.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = native.target as HTMLCanvasElement;
|
||||
|
||||
canvas.style.cursor = firstElement ? 'pointer' : 'default';
|
||||
};
|
||||
|
||||
export const renderNonDoughnutChartLabel = (labelToPick: 'yLabel' | 'xLabel') => (
|
||||
item: ChartTooltipItem,
|
||||
{ datasets }: ChartData,
|
||||
) => {
|
||||
const { datasetIndex } = item;
|
||||
const value = item[labelToPick];
|
||||
const datasetLabel = datasetIndex !== undefined && datasets?.[datasetIndex]?.label || '';
|
||||
export const renderChartLabel = ({ dataset, formattedValue }: TooltipItem<ChartType>) =>
|
||||
`${dataset.label}: ${prettify(formattedValue)}`;
|
||||
|
||||
return `${datasetLabel}: ${prettify(Number(value))}`;
|
||||
};
|
||||
|
||||
export const renderDoughnutChartLabel = (
|
||||
{ datasetIndex, index }: ChartTooltipItem,
|
||||
{ labels, datasets }: ChartData,
|
||||
) => {
|
||||
const datasetLabel = index !== undefined && labels?.[index] || '';
|
||||
const value = datasetIndex !== undefined && index !== undefined
|
||||
&& datasets?.[datasetIndex]?.data?.[index]
|
||||
|| '';
|
||||
|
||||
return `${datasetLabel}: ${prettify(Number(value))}`; // eslint-disable-line @typescript-eslint/no-base-to-string
|
||||
};
|
||||
export const renderPieChartLabel = ({ label, formattedValue }: TooltipItem<ChartType>) =>
|
||||
`${label}: ${prettify(formattedValue)}`;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user