mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-25 11:16:36 +00:00
Compare commits
247 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47f5f47867 | ||
|
|
70d4572797 | ||
|
|
8bfa14386b | ||
|
|
9f6401c30b | ||
|
|
14b2ee53b5 | ||
|
|
7db9974e8d | ||
|
|
7d29129ca1 | ||
|
|
42152c6872 | ||
|
|
b7e9afd54a | ||
|
|
3bc9bd2ef8 | ||
|
|
7bc3819ebe | ||
|
|
0642443aa9 | ||
|
|
2e77cd1969 | ||
|
|
21b8e05e35 | ||
|
|
ed038b9799 | ||
|
|
5f33059de1 | ||
|
|
3bc5b4c154 | ||
|
|
a2421ee2d3 | ||
|
|
109baef828 | ||
|
|
303900756d | ||
|
|
fe81e023e8 | ||
|
|
5906921eec | ||
|
|
ee826458be | ||
|
|
7169c6e083 | ||
|
|
0bb5c7d8af | ||
|
|
a6892b8a12 | ||
|
|
765c4713a2 | ||
|
|
e6737ff1f2 | ||
|
|
7a2d0e5dee | ||
|
|
daf076a57e | ||
|
|
af08b53002 | ||
|
|
39d5853fe3 | ||
|
|
9cbeef1cb4 | ||
|
|
2857e59273 | ||
|
|
04571ea634 | ||
|
|
5241925acc | ||
|
|
844cf51d04 | ||
|
|
b0c1549005 | ||
|
|
16d2e437b6 | ||
|
|
944b166e43 | ||
|
|
e5f99d0893 | ||
|
|
57e73dcba6 | ||
|
|
80f0f9bd08 | ||
|
|
1486d1fba5 | ||
|
|
e28f74169d | ||
|
|
2375882c73 | ||
|
|
7b344998ea | ||
|
|
e8ea3b4abe | ||
|
|
bd0fca23cf | ||
|
|
6d392ba403 | ||
|
|
e135dd92ec | ||
|
|
36af3c3dd0 | ||
|
|
c0e33d6a6a | ||
|
|
41398f659e | ||
|
|
8618519b6b | ||
|
|
c7c32b494e | ||
|
|
ec9fd67b8a | ||
|
|
7637ce3107 | ||
|
|
ada5488a6c | ||
|
|
478209f50d | ||
|
|
7f4263966e | ||
|
|
002f280364 | ||
|
|
d8a6676d30 | ||
|
|
beff6668de | ||
|
|
4baa901f1c | ||
|
|
f19746cd58 | ||
|
|
85161915b1 | ||
|
|
29bf53bf88 | ||
|
|
d2284cd181 | ||
|
|
88305a57bf | ||
|
|
f4908cacc3 | ||
|
|
2925752fde | ||
|
|
1bf3569774 | ||
|
|
9e6907deb4 | ||
|
|
eaa6efe803 | ||
|
|
d38020e2d1 | ||
|
|
4c1d285d04 | ||
|
|
c71e0919e9 | ||
|
|
a295734c13 | ||
|
|
d00b6165b3 | ||
|
|
0cbba1182f | ||
|
|
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 |
@@ -5,3 +5,4 @@
|
||||
./test
|
||||
./shlink-web-client.gif
|
||||
./dist
|
||||
./docs
|
||||
|
||||
@@ -14,5 +14,9 @@
|
||||
"process": true,
|
||||
"setImmediate": true
|
||||
},
|
||||
"ignorePatterns": ["src/service*.ts"]
|
||||
"ignorePatterns": ["src/service*.ts"],
|
||||
"rules": {
|
||||
"complexity": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "off"
|
||||
}
|
||||
}
|
||||
|
||||
56
.github/workflows/ci.yml
vendored
56
.github/workflows/ci.yml
vendored
@@ -5,54 +5,12 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Use node.js 14.15
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.15
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
|
||||
unit-tests:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Use node.js 14.15
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.15
|
||||
- run: npm ci
|
||||
- run: npm run test:ci
|
||||
- name: Publish coverage
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
file: ./coverage/clover.xml
|
||||
|
||||
mutation-tests:
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0 # needed so that the main branch is also fetched
|
||||
- name: Use node.js 14.15
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.15
|
||||
- run: npm ci
|
||||
- run: npm run mutate -- --mutate=$(git diff origin/main --name-only | grep -E 'src\/(.*).(ts|tsx)$' | paste -sd ",")
|
||||
|
||||
build-docker-image:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- run: docker build -t shlink-web-client:test .
|
||||
ci:
|
||||
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
|
||||
with:
|
||||
node-version: 16.13
|
||||
with-mutation-tests: true
|
||||
publish-coverage: true
|
||||
|
||||
22
.github/workflows/deploy-preview.yml
vendored
22
.github/workflows/deploy-preview.yml
vendored
@@ -13,29 +13,17 @@ jobs:
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
- name: Use node.js 14.15
|
||||
- name: Use node.js
|
||||
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)"
|
||||
node-version: 16.13
|
||||
- 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*'
|
||||
|
||||
|
||||
5
.github/workflows/publish-release.yml
vendored
5
.github/workflows/publish-release.yml
vendored
@@ -11,17 +11,16 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Use node.js 14.15
|
||||
- name: Use node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.15
|
||||
node-version: 16.13
|
||||
- name: Generate release assets
|
||||
run: npm ci && npm run build ${GITHUB_REF#refs/tags/v}
|
||||
- name: Publish release with assets
|
||||
uses: docker://antonyurchenko/git-release:latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ALLOW_TAG_PREFIX: "true"
|
||||
ALLOW_EMPTY_CHANGELOG: "true"
|
||||
with:
|
||||
args: |
|
||||
|
||||
158
CHANGELOG.md
158
CHANGELOG.md
@@ -4,6 +4,164 @@ 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.4.1] - 2021-11-20
|
||||
### Added
|
||||
* [#525](https://github.com/shlinkio/shlink-web-client/issues/525) Added docs section for Architectural Decision Records, including the one for servers "auto-connect".
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#520](https://github.com/shlinkio/shlink-web-client/issues/520) Fixed landing page scroll on mobile devices and improved its design.
|
||||
* [#526](https://github.com/shlinkio/shlink-web-client/issues/526) Ensured exported servers do not include the `autoConnect` prop.
|
||||
|
||||
|
||||
## [3.4.0] - 2021-11-11
|
||||
### Added
|
||||
* [#496](https://github.com/shlinkio/shlink-web-client/issues/496) Allowed to select "all visits" as the default interval for visits.
|
||||
* [#500](https://github.com/shlinkio/shlink-web-client/issues/500) Allowed to set the `forwardQuery` flag when creating/editing short URLs on a Shlink v2.9.0 server.
|
||||
* [#508](https://github.com/shlinkio/shlink-web-client/issues/508) Added new servers management section.
|
||||
* [#490](https://github.com/shlinkio/shlink-web-client/issues/490) Now a server can be marked as auto-connect, skipping home screen when that happens.
|
||||
* [#492](https://github.com/shlinkio/shlink-web-client/issues/492) Improved tags table, by supporting sorting by column and making the header sticky.
|
||||
* [#515](https://github.com/shlinkio/shlink-web-client/issues/515) Allowed to sort tags even when using the cards display mode.
|
||||
* [#518](https://github.com/shlinkio/shlink-web-client/issues/518) Improved short URLs list filtering by moving selected tags, search text and dates to the query string, allowing to navigate back and forth or even bookmark filters.
|
||||
|
||||
### Changed
|
||||
* Moved ci workflow to external repo and reused
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#252](https://github.com/shlinkio/shlink-web-client/issues/252) Fixed visits coming from mercure being added in real time, even when selected date interval does not match tha visit's date.
|
||||
* [#48](https://github.com/shlinkio/shlink-web-client/issues/48) Fixed error when selected page gets out of range after filtering short URLs list by text, tags or dates. Now the page is reset to 1 in any of those cases.
|
||||
|
||||
|
||||
## [3.3.2] - 2021-10-17
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#503](https://github.com/shlinkio/shlink-web-client/issues/503) Fixed short URLs title not being resettable after creation.
|
||||
|
||||
|
||||
## [3.3.1] - 2021-09-27
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#497](https://github.com/shlinkio/shlink-web-client/issues/497) Fixed crash in domains section when one of the domains have more than one dot.
|
||||
|
||||
|
||||
## [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:16.13-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
|
||||
|
||||
23
README.md
23
README.md
@@ -1,7 +1,7 @@
|
||||
# shlink-web-client
|
||||
|
||||
[](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
|
||||
[](https://app.codecov.io/gh/shlinkio/shlink-web-client)
|
||||
[](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
|
||||
[](https://app.codecov.io/gh/shlinkio/shlink-web-client)
|
||||
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
||||
@@ -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:16.13-alpine
|
||||
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# How to handle setting auto-connect on servers
|
||||
|
||||
* Status: Accepted
|
||||
* Date: 2021-10-31
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
A new feature has been requested, to allow auto-connecting to servers. The request specifically mentioned doing it automatically when there's only one server configured, but it can be extended a bit to allow setting an "auto-connect" server, regardless the number of configured servers.
|
||||
|
||||
At all times, no more than one server can be set to "auto-connect" simultaneously. Setting a new one will effectively unset the previous one, if any.
|
||||
|
||||
## Considered option
|
||||
|
||||
* Auto-connect only of there's a single server configured.
|
||||
* Allow to set the server as "auto-connect" during server creation, edition or import.
|
||||
* Allow to set the server as "auto-connect" on a separated flow, where the full list of servers can be handled.
|
||||
|
||||
## Decision outcome
|
||||
|
||||
In order to make it more flexible, any server will be allowed to be set as "auto-connect", regardless the amount of configured servers.
|
||||
|
||||
Auto-connect will be handled from the new "Manage servers" section.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Only one server
|
||||
|
||||
* Good:
|
||||
* Does not require extending models, and the logic to auto-connect is based on the amount of configured servers.
|
||||
* Bad:
|
||||
* It's not flexible enough.
|
||||
* Makes the app behave differently depending on the amount of configured servers, making it confusing.
|
||||
|
||||
### Auto-connect configured on existing creation/edition/import
|
||||
|
||||
* Good:
|
||||
* Does not require creating a new section to handle "auto-connect".
|
||||
* Bad:
|
||||
* Requires extending the server model with a new prop.
|
||||
* It's much harder to ensure data consistency, as we need to ensure only one server is set to "auto-connect".
|
||||
* On import, many servers might have been set to "auto-connect". The expected behavior there can be unclear.
|
||||
|
||||
### Auto-connect configured on new section
|
||||
|
||||
* Good:
|
||||
* It's much easier to ensure data consistency.
|
||||
* It's much more clear and predictable, as the UI shows which is the server configured as auto-connect.
|
||||
* We have controls in a single place to set/unset auto connect on servers, allowing only the proper option based on current state for every server.
|
||||
* Bad:
|
||||
* Requires extending the server model with a new prop.
|
||||
* Requires creating a new section to handle "auto-connect".
|
||||
5
docs/adr/README.md
Normal file
5
docs/adr/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Architectural Decision Records
|
||||
|
||||
Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome.
|
||||
|
||||
* [2021-10-31 How to handle setting auto-connect on servers](2021-10-31-how-to-handle-setting-auto-connect-on-servers.md)
|
||||
@@ -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: 80,
|
||||
functions: 80,
|
||||
lines: 85,
|
||||
},
|
||||
},
|
||||
resolver: 'jest-pnp-resolver',
|
||||
setupFiles: [
|
||||
'react-app-polyfill/jsdom',
|
||||
|
||||
42784
package-lock.json
generated
42784
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
51
package.json
51
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",
|
||||
@@ -69,20 +69,17 @@
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
|
||||
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
|
||||
"@stryker-mutator/core": "^5.0.0",
|
||||
"@stryker-mutator/jest-runner": "^5.0.0",
|
||||
"@stryker-mutator/typescript-checker": "^5.0.0",
|
||||
"@stryker-mutator/core": "^5.4.1",
|
||||
"@stryker-mutator/jest-runner": "^5.4.1",
|
||||
"@stryker-mutator/typescript-checker": "^5.4.1",
|
||||
"@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/enzyme": "^3.10.10",
|
||||
"@types/jest": "^27.0.2",
|
||||
"@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,20 +87,20 @@
|
||||
"@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",
|
||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
|
||||
"adm-zip": "^0.4.16",
|
||||
"autoprefixer": "^10.0.2",
|
||||
"babel-core": "7.0.0-bridge.0",
|
||||
"babel-jest": "^26.6.3",
|
||||
"babel-jest": "^27.3.1",
|
||||
"babel-loader": "^8.2.1",
|
||||
"babel-plugin-named-asset-import": "^0.3.7",
|
||||
"babel-preset-react-app": "^10.0.0",
|
||||
"babel-runtime": "^6.26.0",
|
||||
"bfj": "^7.0.2",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.3.0",
|
||||
"chalk": "^4.1.0",
|
||||
"chalk": "^4.1.2",
|
||||
"css-loader": "^5.0.1",
|
||||
"dart-sass": "^1.25.0",
|
||||
"dotenv": "^8.2.0",
|
||||
@@ -116,9 +113,9 @@
|
||||
"fs-extra": "^9.0.1",
|
||||
"html-webpack-plugin": "^4.5.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^26.6.3",
|
||||
"jest": "^27.3.1",
|
||||
"jest-pnp-resolver": "^1.2.2",
|
||||
"jest-resolve": "^26.6.2",
|
||||
"jest-resolve": "^27.3.1",
|
||||
"mini-css-extract-plugin": "^1.3.1",
|
||||
"object-assign": "^4.1.1",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.4",
|
||||
@@ -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",
|
||||
@@ -144,9 +141,9 @@
|
||||
"stylelint-scss": "^3.18.0",
|
||||
"sw-precache-webpack-plugin": "^1.0.0",
|
||||
"terser-webpack-plugin": "^4.2.3",
|
||||
"ts-jest": "^26.5.2",
|
||||
"ts-jest": "^27.0.7",
|
||||
"ts-mockery": "^1.2.0",
|
||||
"typescript": "^4.2.2",
|
||||
"typescript": "^4.4.4",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^4.44.2",
|
||||
"webpack-dev-server": "^3.11.0",
|
||||
|
||||
@@ -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,7 +1,5 @@
|
||||
import qs from 'qs';
|
||||
import { isEmpty, isNil, reject } from 'ramda';
|
||||
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
||||
import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||
import { ShortUrl, ShortUrlData } from '../../short-urls/data';
|
||||
import { OptionalString } from '../../utils/utils';
|
||||
import {
|
||||
@@ -16,7 +14,11 @@ import {
|
||||
ShlinkDomain,
|
||||
ShlinkDomainsResponse,
|
||||
ShlinkVisitsOverview,
|
||||
ShlinkEditDomainRedirects,
|
||||
ShlinkDomainRedirects,
|
||||
ShlinkShortUrlsListParams,
|
||||
} from '../types';
|
||||
import { stringifyQuery } from '../../utils/helpers/query';
|
||||
|
||||
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
||||
const rejectNilProps = reject(isNil);
|
||||
@@ -32,7 +34,7 @@ export default class ShlinkApiClient {
|
||||
this.apiVersion = 2;
|
||||
}
|
||||
|
||||
public readonly listShortUrls = async (params: ShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
|
||||
public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
|
||||
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params)
|
||||
.then(({ data }) => data.shortUrls);
|
||||
|
||||
@@ -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,9 +123,9 @@ 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) {
|
||||
} catch (e: any) {
|
||||
const { response } = e;
|
||||
|
||||
// Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Visit } from '../../visits/types';
|
||||
import { OptionalString } from '../../utils/utils';
|
||||
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
|
||||
import { OrderBy } from '../../short-urls/reducers/shortUrlsListParams';
|
||||
|
||||
export interface ShlinkShortUrlsResponse {
|
||||
data: ShortUrl[];
|
||||
@@ -25,12 +26,12 @@ interface ShlinkTagsStats {
|
||||
|
||||
export interface ShlinkTags {
|
||||
tags: string[];
|
||||
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2
|
||||
stats: ShlinkTagsStats[];
|
||||
}
|
||||
|
||||
export interface ShlinkTagsResponse {
|
||||
data: string[];
|
||||
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2
|
||||
stats: ShlinkTagsStats[];
|
||||
}
|
||||
|
||||
export interface ShlinkPaginator {
|
||||
@@ -55,6 +56,7 @@ export interface ShlinkVisitsParams {
|
||||
itemsPerPage?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
excludeBots?: boolean;
|
||||
}
|
||||
|
||||
export interface ShlinkShortUrlData extends ShortUrlMeta {
|
||||
@@ -64,21 +66,41 @@ 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 {
|
||||
data: ShlinkDomain[];
|
||||
}
|
||||
|
||||
export interface ShlinkShortUrlsListParams {
|
||||
page?: string;
|
||||
itemsPerPage?: number;
|
||||
tags?: string[];
|
||||
searchTerm?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
orderBy?: OrderBy;
|
||||
}
|
||||
|
||||
export interface ProblemDetailsError {
|
||||
type: string;
|
||||
detail: string;
|
||||
title: string;
|
||||
status: number;
|
||||
|
||||
[extraProps: string]: any;
|
||||
}
|
||||
|
||||
|
||||
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,14 +1,15 @@
|
||||
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 { Route, RouteChildrenProps, Switch } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
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 {
|
||||
interface AppProps extends RouteChildrenProps {
|
||||
fetchServers: () => void;
|
||||
servers: ServersMap;
|
||||
settings: Settings;
|
||||
@@ -23,8 +24,11 @@ const App = (
|
||||
CreateServer: FC,
|
||||
EditServer: FC,
|
||||
Settings: FC,
|
||||
ManageServers: FC,
|
||||
ShlinkVersionsContainer: FC,
|
||||
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
|
||||
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate, location }: AppProps) => {
|
||||
const isHome = location.pathname === '/';
|
||||
|
||||
useEffect(() => {
|
||||
// On first load, try to fetch the remote servers if the list is empty
|
||||
if (Object.keys(servers).length === 0) {
|
||||
@@ -39,10 +43,11 @@ const App = (
|
||||
<MainHeader />
|
||||
|
||||
<div className="app">
|
||||
<div className="shlink-wrapper">
|
||||
<div className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
|
||||
<Switch>
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route exact path="/settings" component={Settings} />
|
||||
<Route exact path="/manage-servers" component={ManageServers} />
|
||||
<Route exact path="/server/create" component={CreateServer} />
|
||||
<Route exact path="/server/:serverId/edit" component={EditServer} />
|
||||
<Route path="/server/:serverId" component={MenuLayout} />
|
||||
@@ -55,16 +60,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,9 +1,9 @@
|
||||
import Bottle from 'bottlejs';
|
||||
import Bottle, { Decorator } 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) => {
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory(
|
||||
'App',
|
||||
@@ -14,9 +14,11 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
'CreateServer',
|
||||
'EditServer',
|
||||
'Settings',
|
||||
'ManageServers',
|
||||
'ShlinkVersionsContainer',
|
||||
);
|
||||
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ]));
|
||||
bottle.decorator('App', withRouter);
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);
|
||||
|
||||
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,9 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
|
||||
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||
{ selectedServer, showOnMobile = false }: AsideMenuProps,
|
||||
) => {
|
||||
const serverId = selectedServer ? selectedServer.id : '';
|
||||
const hasId = isServerWithId(selectedServer);
|
||||
const serverId = hasId ? selectedServer.id : '';
|
||||
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
|
||||
const asideClass = classNames('aside-menu', {
|
||||
'aside-menu--hidden': !showOnMobile,
|
||||
});
|
||||
@@ -49,30 +53,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}
|
||||
/>
|
||||
{hasId && (
|
||||
<DeleteServerButton
|
||||
className="aside-menu__item aside-menu__item--danger"
|
||||
textClassName="aside-menu__item-text"
|
||||
server={selectedServer}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
.home {
|
||||
position: relative;
|
||||
padding-top: 15px;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding-top: 0;
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import { useEffect } from 'react';
|
||||
import { isEmpty, values } from 'ramda';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, RouteChildrenProps } 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';
|
||||
import './Home.scss';
|
||||
|
||||
export interface HomeProps {
|
||||
export interface HomeProps extends RouteChildrenProps {
|
||||
servers: ServersMap;
|
||||
}
|
||||
|
||||
const Home = ({ servers }: HomeProps) => {
|
||||
const Home = ({ servers, history }: HomeProps) => {
|
||||
const serversList = values(servers);
|
||||
const hasServers = !isEmpty(serversList);
|
||||
|
||||
useEffect(() => {
|
||||
// Try to redirect to the first server marked as auto-connect
|
||||
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
|
||||
|
||||
autoConnectServer && history.push(`/server/${autoConnectServer.id}`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<Card className="home__main-card">
|
||||
@@ -30,12 +40,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';
|
||||
@@ -13,7 +13,7 @@ import './MenuLayout.scss';
|
||||
|
||||
const MenuLayout = (
|
||||
TagsList: FC,
|
||||
ShortUrls: FC,
|
||||
ShortUrlsList: FC,
|
||||
AsideMenu: FC<AsideMenuProps>,
|
||||
CreateShortUrl: FC,
|
||||
ShortUrlVisits: FC,
|
||||
@@ -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);
|
||||
|
||||
@@ -48,13 +49,14 @@ const MenuLayout = (
|
||||
<Switch>
|
||||
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
|
||||
<Route exact path="/server/:serverId/overview" component={Overview} />
|
||||
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
||||
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrlsList} />
|
||||
<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);
|
||||
|
||||
@@ -23,13 +28,14 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||
|
||||
bottle.serviceFactory('Home', () => Home);
|
||||
bottle.decorator('Home', withoutSelectedServer);
|
||||
bottle.decorator('Home', withRouter);
|
||||
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
|
||||
|
||||
bottle.serviceFactory(
|
||||
'MenuLayout',
|
||||
MenuLayout,
|
||||
'TagsList',
|
||||
'ShortUrls',
|
||||
'ShortUrlsList',
|
||||
'AsideMenu',
|
||||
'CreateShortUrl',
|
||||
'ShortUrlVisits',
|
||||
@@ -38,8 +44,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||
'ServerError',
|
||||
'Overview',
|
||||
'EditShortUrl',
|
||||
'ManageDomains',
|
||||
);
|
||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
||||
bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer' ]));
|
||||
bottle.decorator('MenuLayout', withRouter);
|
||||
|
||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||
|
||||
@@ -33,10 +33,10 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
|
||||
actionServiceNames.reduce(mapActionService, {}),
|
||||
);
|
||||
|
||||
provideAppServices(bottle, connect);
|
||||
provideAppServices(bottle, connect, withRouter);
|
||||
provideCommonServices(bottle, connect, withRouter);
|
||||
provideApiServices(bottle);
|
||||
provideShortUrlsServices(bottle, connect);
|
||||
provideShortUrlsServices(bottle, connect, withRouter);
|
||||
provideServersServices(bottle, connect, withRouter);
|
||||
provideTagsServices(bottle, connect);
|
||||
provideVisitsServices(bottle, connect);
|
||||
|
||||
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(/\./g, '')}`;
|
||||
|
||||
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: any) {
|
||||
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 (
|
||||
@@ -43,7 +71,9 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
|
||||
const domains = await listDomains();
|
||||
|
||||
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
|
||||
} catch (e) {
|
||||
dispatch({ type: LIST_DOMAINS_ERROR });
|
||||
} catch (e: any) {
|
||||
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;
|
||||
@@ -113,6 +115,16 @@ hr {
|
||||
color: var(--text-color) !important;
|
||||
}
|
||||
|
||||
.dropdown-item--danger.dropdown-item--danger {
|
||||
color: $dangerColor;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: $dangerColor !important;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-main {
|
||||
color: #ffffff;
|
||||
background-color: var(--brand-color);
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -8,9 +8,3 @@
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.create-server__csv-select {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
import { FC } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { RouterProps } from 'react-router';
|
||||
import { Button } from 'reactstrap';
|
||||
import { Result } from '../utils/Result';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||
import { ServerData, ServerWithId } from './data';
|
||||
import { ServerData, ServersMap, ServerWithId } from './data';
|
||||
import './CreateServer.scss';
|
||||
|
||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||
|
||||
interface CreateServerProps extends RouterProps {
|
||||
createServer: (server: ServerWithId) => void;
|
||||
servers: ServersMap;
|
||||
}
|
||||
|
||||
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
||||
<Result type={type}>
|
||||
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
||||
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
||||
</Result>
|
||||
<div className="mt-3">
|
||||
<Result type={type}>
|
||||
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
||||
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
||||
</Result>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
|
||||
{ createServer, history: { push } }: CreateServerProps,
|
||||
{ servers, createServer, history: { push, goBack } }: CreateServerProps,
|
||||
) => {
|
||||
const hasServers = !!Object.keys(servers).length;
|
||||
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||
const handleSubmit = (serverData: ServerData) => {
|
||||
@@ -37,16 +42,14 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}>
|
||||
<ImportServersBtn onImport={setServersImported} onImportError={setErrorImporting} />
|
||||
<button className="btn btn-outline-primary">Create server</button>
|
||||
{!hasServers &&
|
||||
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />}
|
||||
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
|
||||
<Button outline color="primary" className="ml-2">Create server</Button>
|
||||
</ServerForm>
|
||||
|
||||
{(serversImported || errorImporting) && (
|
||||
<div className="mt-3">
|
||||
{serversImported && <ImportResult type="success" />}
|
||||
{errorImporting && <ImportResult type="error" />}
|
||||
</div>
|
||||
)}
|
||||
{serversImported && <ImportResult type="success" />}
|
||||
{errorImporting && <ImportResult type="error" />}
|
||||
</NoMenuLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FC } from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { RouterProps } from 'react-router';
|
||||
import { ServerWithId } from './data';
|
||||
@@ -6,17 +7,20 @@ export interface DeleteServerModalProps {
|
||||
server: ServerWithId;
|
||||
toggle: () => void;
|
||||
isOpen: boolean;
|
||||
redirectHome?: boolean;
|
||||
}
|
||||
|
||||
interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps {
|
||||
deleteServer: (server: ServerWithId) => void;
|
||||
}
|
||||
|
||||
const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }: DeleteServerModalConnectProps) => {
|
||||
const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
||||
{ server, toggle, isOpen, deleteServer, history, redirectHome = true },
|
||||
) => {
|
||||
const closeModal = () => {
|
||||
deleteServer(server);
|
||||
toggle();
|
||||
history.push('/');
|
||||
redirectHome && history.push('/');
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,7 +10,7 @@ interface EditServerProps {
|
||||
}
|
||||
|
||||
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
|
||||
{ editServer, selectedServer, history: { push, goBack } },
|
||||
{ editServer, selectedServer, history: { goBack } },
|
||||
) => {
|
||||
if (!isServerWithId(selectedServer)) {
|
||||
return null;
|
||||
@@ -18,7 +18,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
|
||||
|
||||
const handleSubmit = (serverData: ServerData) => {
|
||||
editServer(selectedServer.id, serverData);
|
||||
push(`/server/${selectedServer.id}`);
|
||||
goBack();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
86
src/servers/ManageServers.tsx
Normal file
86
src/servers/ManageServers.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { Button, Row } from 'reactstrap';
|
||||
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Link } from 'react-router-dom';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { Result } from '../utils/Result';
|
||||
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
||||
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||
import { ServersMap } from './data';
|
||||
import { ManageServersRowProps } from './ManageServersRow';
|
||||
import ServersExporter from './services/ServersExporter';
|
||||
|
||||
interface ManageServersProps {
|
||||
servers: ServersMap;
|
||||
}
|
||||
|
||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||
|
||||
export const ManageServers = (
|
||||
serversExporter: ServersExporter,
|
||||
ImportServersBtn: FC<ImportServersBtnProps>,
|
||||
useStateFlagTimeout: StateFlagTimeout,
|
||||
ManageServersRow: FC<ManageServersRowProps>,
|
||||
): FC<ManageServersProps> => ({ servers }) => {
|
||||
const allServers = Object.values(servers);
|
||||
const [ serversList, setServersList ] = useState(allServers);
|
||||
const filterServers = (searchTerm: string) => setServersList(
|
||||
allServers.filter(({ name, url }) => `${name} ${url}`.match(searchTerm)),
|
||||
);
|
||||
const hasAutoConnect = serversList.some(({ autoConnect }) => !!autoConnect);
|
||||
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||
|
||||
useEffect(() => {
|
||||
setServersList(Object.values(servers));
|
||||
}, [ servers ]);
|
||||
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<SearchField className="mb-3" onChange={filterServers} />
|
||||
|
||||
<Row className="mb-3">
|
||||
<div className="col-md-6 d-flex d-md-block mb-2 mb-md-0">
|
||||
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
|
||||
{allServers.length > 0 && (
|
||||
<Button outline className="ml-2 flex-fill" onClick={async () => serversExporter.exportServers()}>
|
||||
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-md-6 text-md-right d-flex d-md-block">
|
||||
<Button outline color="primary" className="flex-fill" tag={Link} to="/server/create">
|
||||
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
|
||||
</Button>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<SimpleCard>
|
||||
<table className="table table-hover mb-0">
|
||||
<thead className="responsive-table__header">
|
||||
<tr>
|
||||
{hasAutoConnect && <th style={{ width: '50px' }} />}
|
||||
<th>Name</th>
|
||||
<th>Base URL</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!serversList.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>}
|
||||
{serversList.map((server) =>
|
||||
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />)
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</SimpleCard>
|
||||
|
||||
{errorImporting && (
|
||||
<div className="mt-3">
|
||||
<Result type="error">The servers could not be imported. Make sure the format is correct.</Result>
|
||||
</div>
|
||||
)}
|
||||
</NoMenuLayout>
|
||||
);
|
||||
};
|
||||
38
src/servers/ManageServersRow.tsx
Normal file
38
src/servers/ManageServersRow.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { FC } from 'react';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ServerWithId } from './data';
|
||||
import { ManageServersRowDropdownProps } from './ManageServersRowDropdown';
|
||||
|
||||
export interface ManageServersRowProps {
|
||||
server: ServerWithId;
|
||||
hasAutoConnect: boolean;
|
||||
}
|
||||
|
||||
export const ManageServersRow = (
|
||||
ManageServersRowDropdown: FC<ManageServersRowDropdownProps>,
|
||||
): FC<ManageServersRowProps> => ({ server, hasAutoConnect }) => (
|
||||
<tr className="responsive-table__row">
|
||||
{hasAutoConnect && (
|
||||
<td className="responsive-table__cell" data-th="Auto-connect">
|
||||
{server.autoConnect && (
|
||||
<>
|
||||
<FontAwesomeIcon icon={checkIcon} className="text-primary" id="autoConnectIcon" />
|
||||
<UncontrolledTooltip target="autoConnectIcon" placement="right">
|
||||
Auto-connect to this server
|
||||
</UncontrolledTooltip>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<th className="responsive-table__cell" data-th="Name">
|
||||
<Link to={`/server/${server.id}`}>{server.name}</Link>
|
||||
</th>
|
||||
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
|
||||
<td className="responsive-table__cell text-right">
|
||||
<ManageServersRowDropdown server={server} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
53
src/servers/ManageServersRowDropdown.tsx
Normal file
53
src/servers/ManageServersRowDropdown.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { FC } from 'react';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBan as toggleOffIcon,
|
||||
faEdit as editIcon,
|
||||
faMinusCircle as deleteIcon,
|
||||
faPlug as connectIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCircle as toggleOnIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { DeleteServerModalProps } from './DeleteServerModal';
|
||||
import { ServerWithId } from './data';
|
||||
|
||||
export interface ManageServersRowDropdownProps {
|
||||
server: ServerWithId;
|
||||
}
|
||||
|
||||
interface ManageServersRowDropdownConnectProps extends ManageServersRowDropdownProps {
|
||||
setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void;
|
||||
}
|
||||
|
||||
export const ManageServersRowDropdown = (
|
||||
DeleteServerModal: FC<DeleteServerModalProps>,
|
||||
): FC<ManageServersRowDropdownConnectProps> => ({ server, setAutoConnect }) => {
|
||||
const [ isMenuOpen, toggleMenu ] = useToggle();
|
||||
const [ isModalOpen,, showModal, hideModal ] = useToggle();
|
||||
const serverUrl = `/server/${server.id}`;
|
||||
const { autoConnect: isAutoConnect } = server;
|
||||
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
|
||||
|
||||
return (
|
||||
<DropdownBtnMenu isOpen={isMenuOpen} toggle={toggleMenu}>
|
||||
<DropdownItem tag={Link} to={serverUrl}>
|
||||
<FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
|
||||
</DropdownItem>
|
||||
<DropdownItem tag={Link} to={`${serverUrl}/edit`}>
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit server
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => setAutoConnect(server, !server.autoConnect)}>
|
||||
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
|
||||
</DropdownItem>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem className="dropdown-item--danger" onClick={showModal}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
|
||||
</DropdownItem>
|
||||
|
||||
<DeleteServerModal redirectHome={false} server={server} isOpen={isModalOpen} toggle={hideModal} />
|
||||
</DropdownBtnMenu>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import { Card, CardBody, CardHeader, CardText, CardTitle, Row } from 'reactstrap';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
|
||||
import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
||||
import { prettify } from '../utils/helpers/numbers';
|
||||
import { TagsList } from '../tags/reducers/tagsList';
|
||||
@@ -11,12 +10,13 @@ import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
||||
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||
import { Versions } from '../utils/helpers/version';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import { isServerWithId, SelectedServer } from './data';
|
||||
import { ShlinkShortUrlsListParams } from '../api/types';
|
||||
import { getServerId, SelectedServer } from './data';
|
||||
import './Overview.scss';
|
||||
|
||||
interface OverviewConnectProps {
|
||||
shortUrlsList: ShortUrlsListState;
|
||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
||||
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
||||
listTags: Function;
|
||||
tagsList: TagsList;
|
||||
selectedServer: SelectedServer;
|
||||
@@ -40,7 +40,7 @@ export const Overview = (
|
||||
const { loading, shortUrls } = shortUrlsList;
|
||||
const { loading: loadingTags } = tagsList;
|
||||
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
|
||||
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
||||
const serverId = getServerId(selectedServer);
|
||||
const history = useHistory();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -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">
|
||||
@@ -114,10 +107,10 @@ export const Overview = (
|
||||
shortUrlsList={shortUrlsList}
|
||||
selectedServer={selectedServer}
|
||||
className="mb-0"
|
||||
onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tag=${tag}`)}
|
||||
onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}, () => [ Topics.visits(), Topics.orphanVisits() ]);
|
||||
}, () => [ Topics.visits, Topics.orphanVisits ]);
|
||||
|
||||
@@ -1,45 +1,37 @@
|
||||
import { isEmpty, values } from 'ramda';
|
||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { faPlus as plusIcon, faFileDownload as exportIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import ServersExporter from './services/ServersExporter';
|
||||
import { isServerWithId, SelectedServer, ServersMap } from './data';
|
||||
import { getServerId, SelectedServer, ServersMap } from './data';
|
||||
|
||||
export interface ServersDropdownProps {
|
||||
servers: ServersMap;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||
const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||
const serversList = values(servers);
|
||||
const createServerItem = (
|
||||
<DropdownItem tag={Link} to="/server/create">
|
||||
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add server</span>
|
||||
</DropdownItem>
|
||||
);
|
||||
|
||||
const renderServers = () => {
|
||||
if (isEmpty(serversList)) {
|
||||
return createServerItem;
|
||||
return (
|
||||
<DropdownItem tag={Link} to="/server/create">
|
||||
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add a server</span>
|
||||
</DropdownItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{serversList.map(({ name, id }) => (
|
||||
<DropdownItem
|
||||
key={id}
|
||||
tag={Link}
|
||||
to={`/server/${id}`}
|
||||
active={isServerWithId(selectedServer) && selectedServer.id === id}
|
||||
>
|
||||
<DropdownItem key={id} tag={Link} to={`/server/${id}`} active={getServerId(selectedServer) === id}>
|
||||
{name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownItem divider />
|
||||
{createServerItem}
|
||||
<DropdownItem className="servers-dropdown__export-item" onClick={async () => serversExporter.exportServers()}>
|
||||
<FontAwesomeIcon icon={exportIcon} /> <span className="ml-1">Export servers</span>
|
||||
<DropdownItem tag={Link} to="/manage-servers">
|
||||
<FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Manage servers</span>
|
||||
</DropdownItem>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { omit } from 'ramda';
|
||||
import { SemVer } from '../../utils/helpers/version';
|
||||
|
||||
export interface ServerData {
|
||||
@@ -8,6 +9,7 @@ export interface ServerData {
|
||||
|
||||
export interface ServerWithId extends ServerData {
|
||||
id: string;
|
||||
autoConnect?: boolean;
|
||||
}
|
||||
|
||||
export interface ReachableServer extends ServerWithId {
|
||||
@@ -40,3 +42,8 @@ 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 : '';
|
||||
|
||||
export const serverWithIdToServerData = (server: ServerWithId): ServerData =>
|
||||
omit<ServerWithId, 'id' | 'autoConnect'>([ 'id', 'autoConnect' ], server);
|
||||
|
||||
5
src/servers/helpers/ImportServersBtn.scss
Normal file
5
src/servers/helpers/ImportServersBtn.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.import-servers-btn__csv-select {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useRef, RefObject, ChangeEvent, MutableRefObject } from 'react';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import { useRef, RefObject, ChangeEvent, MutableRefObject, FC } from 'react';
|
||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||
import { pipe } from 'ramda';
|
||||
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import ServersImporter from '../services/ServersImporter';
|
||||
import { ServerData } from '../data';
|
||||
import './ImportServersBtn.scss';
|
||||
|
||||
type Ref<T> = RefObject<T> | MutableRefObject<T>;
|
||||
|
||||
export interface ImportServersBtnProps {
|
||||
onImport?: () => void;
|
||||
onImportError?: () => void;
|
||||
onImportError?: (error: Error) => void;
|
||||
tooltipPlacement?: 'top' | 'bottom';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
||||
@@ -15,17 +21,19 @@ interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
||||
fileRef: Ref<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
|
||||
const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<ImportServersBtnConnectProps> => ({
|
||||
createServers,
|
||||
fileRef,
|
||||
children,
|
||||
onImport = () => {},
|
||||
onImportError = () => {},
|
||||
}: ImportServersBtnConnectProps) => {
|
||||
tooltipPlacement = 'bottom',
|
||||
className = '',
|
||||
}) => {
|
||||
const ref = fileRef ?? useRef<HTMLInputElement>();
|
||||
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||
importServersFromFile(target.files?.[0])
|
||||
.then(createServers)
|
||||
.then(onImport)
|
||||
.then(pipe(createServers, onImport))
|
||||
.then(() => {
|
||||
// Reset input after processing file
|
||||
(target as { value: string | null }).value = null;
|
||||
@@ -34,19 +42,14 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary mr-2"
|
||||
id="importBtn"
|
||||
onClick={() => ref.current?.click()}
|
||||
>
|
||||
Import from file
|
||||
</button>
|
||||
<UncontrolledTooltip placement="top" target="importBtn">
|
||||
<Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}>
|
||||
<FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? 'Import from file'}
|
||||
</Button>
|
||||
<UncontrolledTooltip placement={tooltipPlacement} target="importBtn">
|
||||
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
|
||||
</UncontrolledTooltip>
|
||||
|
||||
<input type="file" accept="text/csv" className="create-server__csv-select" ref={ref} onChange={onChange} />
|
||||
<input type="file" accept="text/csv" className="import-servers-btn__csv-select" ref={ref} onChange={onChange} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}>API key</FormGroup>
|
||||
</SimpleCard>
|
||||
|
||||
<div className="text-right">{children}</div>
|
||||
|
||||
@@ -2,24 +2,17 @@ import { pipe, prop } from 'ramda';
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { Dispatch } from 'redux';
|
||||
import { homepage } from '../../../package.json';
|
||||
import { ServerData } from '../data';
|
||||
import { hasServerData, ServerData } from '../data';
|
||||
import { createServers } from './servers';
|
||||
|
||||
const responseToServersList = pipe(
|
||||
prop<any, any>('data'),
|
||||
(data: any): ServerData[] => {
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Value is not an array');
|
||||
}
|
||||
|
||||
return data as ServerData[];
|
||||
},
|
||||
(data: any): ServerData[] => Array.isArray(data) ? data.filter(hasServerData) : [],
|
||||
);
|
||||
|
||||
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {
|
||||
const remoteList = await get(`${homepage}/servers.json`)
|
||||
.then(responseToServersList)
|
||||
.catch(() => []);
|
||||
const resp = await get(`${homepage}/servers.json`);
|
||||
const remoteList = responseToServersList(resp);
|
||||
|
||||
dispatch(createServers(remoteList));
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { assoc, dissoc, map, pipe, reduce } from 'ramda';
|
||||
import { assoc, dissoc, fromPairs, map, pipe, reduce, toPairs } from 'ramda';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Action } from 'redux';
|
||||
import { ServerData, ServersMap, ServerWithId } from '../data';
|
||||
@@ -8,12 +8,22 @@ import { buildReducer } from '../../utils/helpers/redux';
|
||||
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
|
||||
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
||||
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
||||
export const SET_AUTO_CONNECT = 'shlink/servers/SET_AUTO_CONNECT';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export interface CreateServersAction extends Action<string> {
|
||||
newServers: ServersMap;
|
||||
}
|
||||
|
||||
interface DeleteServerAction extends Action<string> {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
interface SetAutoConnectAction extends Action<string> {
|
||||
serverId: string;
|
||||
autoConnect: boolean;
|
||||
}
|
||||
|
||||
const initialState: ServersMap = {};
|
||||
|
||||
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
||||
@@ -24,12 +34,28 @@ const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
||||
return assoc('id', uuid(), server);
|
||||
};
|
||||
|
||||
export default buildReducer<ServersMap, CreateServersAction>({
|
||||
export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction & SetAutoConnectAction>({
|
||||
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
|
||||
[DELETE_SERVER]: (state, { serverId }: any) => dissoc(serverId, state),
|
||||
[DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state),
|
||||
[EDIT_SERVER]: (state, { serverId, serverData }: any) => !state[serverId]
|
||||
? state
|
||||
: assoc(serverId, { ...state[serverId], ...serverData }, state),
|
||||
[SET_AUTO_CONNECT]: (state, { serverId, autoConnect }) => {
|
||||
if (!state[serverId]) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (!autoConnect) {
|
||||
return assoc(serverId, { ...state[serverId], autoConnect }, state);
|
||||
}
|
||||
|
||||
return fromPairs(
|
||||
toPairs(state).map(([ evaluatedServerId, server ]) => [
|
||||
evaluatedServerId,
|
||||
{ ...server, autoConnect: evaluatedServerId === serverId },
|
||||
]),
|
||||
);
|
||||
},
|
||||
}, initialState);
|
||||
|
||||
const serversListToMap = reduce<ServerWithId, ServersMap>((acc, server) => assoc(server.id, server, acc), {});
|
||||
@@ -48,4 +74,10 @@ export const editServer = (serverId: string, serverData: Partial<ServerData>) =>
|
||||
serverData,
|
||||
});
|
||||
|
||||
export const deleteServer = ({ id }: ServerWithId) => ({ type: DELETE_SERVER, serverId: id });
|
||||
export const deleteServer = ({ id }: ServerWithId): DeleteServerAction => ({ type: DELETE_SERVER, serverId: id });
|
||||
|
||||
export const setAutoConnect = ({ id }: ServerWithId, autoConnect: boolean): SetAutoConnectAction => ({
|
||||
type: SET_AUTO_CONNECT,
|
||||
serverId: id,
|
||||
autoConnect,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { dissoc, values } from 'ramda';
|
||||
import { values } from 'ramda';
|
||||
import { CsvJson } from 'csvjson';
|
||||
import LocalStorage from '../../utils/services/LocalStorage';
|
||||
import { ServersMap } from '../data';
|
||||
import { saveCsv } from '../../utils/helpers/csv';
|
||||
import { ServersMap, serverWithIdToServerData } from '../data';
|
||||
import { saveCsv } from '../../utils/helpers/files';
|
||||
|
||||
const SERVERS_FILENAME = 'shlink-servers.csv';
|
||||
|
||||
@@ -14,7 +14,7 @@ export default class ServersExporter {
|
||||
) {}
|
||||
|
||||
public readonly exportServers = async () => {
|
||||
const servers = values(this.storage.get<ServersMap>('servers') ?? {}).map(dissoc('id'));
|
||||
const servers = values(this.storage.get<ServersMap>('servers') ?? {}).map(serverWithIdToServerData);
|
||||
|
||||
try {
|
||||
const csv = this.csvjson.toCSV(servers, { headers: 'key' });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -7,26 +7,44 @@ import DeleteServerButton from '../DeleteServerButton';
|
||||
import { EditServer } from '../EditServer';
|
||||
import ImportServersBtn from '../helpers/ImportServersBtn';
|
||||
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
|
||||
import { createServer, createServers, deleteServer, editServer } from '../reducers/servers';
|
||||
import { createServer, createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
|
||||
import { fetchServers } from '../reducers/remoteServers';
|
||||
import ForServerVersion from '../helpers/ForServerVersion';
|
||||
import { ServerError } from '../helpers/ServerError';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
|
||||
import { Overview } from '../Overview';
|
||||
import { ManageServers } from '../ManageServers';
|
||||
import { ManageServersRow } from '../ManageServersRow';
|
||||
import { ManageServersRowDropdown } from '../ManageServersRowDropdown';
|
||||
import ServersImporter from './ServersImporter';
|
||||
import ServersExporter from './ServersExporter';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory(
|
||||
'ManageServers',
|
||||
ManageServers,
|
||||
'ServersExporter',
|
||||
'ImportServersBtn',
|
||||
'useStateFlagTimeout',
|
||||
'ManageServersRow',
|
||||
);
|
||||
bottle.decorator('ManageServers', connect([ 'servers' ]));
|
||||
|
||||
bottle.serviceFactory('ManageServersRow', ManageServersRow, 'ManageServersRowDropdown');
|
||||
|
||||
bottle.serviceFactory('ManageServersRowDropdown', ManageServersRowDropdown, 'DeleteServerModal');
|
||||
bottle.decorator('ManageServersRowDropdown', connect(null, [ 'setAutoConnect' ]));
|
||||
|
||||
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
|
||||
bottle.decorator('CreateServer', withoutSelectedServer);
|
||||
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
|
||||
bottle.decorator('CreateServer', connect([ 'selectedServer', 'servers' ], [ 'createServer', 'resetSelectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
|
||||
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
|
||||
|
||||
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
|
||||
bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
|
||||
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
||||
@@ -62,6 +80,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||
bottle.serviceFactory('createServers', () => createServers);
|
||||
bottle.serviceFactory('deleteServer', () => deleteServer);
|
||||
bottle.serviceFactory('editServer', () => editServer);
|
||||
bottle.serviceFactory('setAutoConnect', () => setAutoConnect);
|
||||
bottle.serviceFactory('fetchServers', fetchServers, 'axios');
|
||||
|
||||
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
||||
|
||||
@@ -18,7 +18,7 @@ const RealTimeUpdates = (
|
||||
<SimpleCard title="Real-time updates" className="h-100">
|
||||
<FormGroup>
|
||||
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
|
||||
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
|
||||
Enable or disable real-time updates.
|
||||
<small className="form-text text-muted">
|
||||
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
|
||||
</small>
|
||||
@@ -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,74 @@
|
||||
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 those <b>including</b> provided input.</>
|
||||
: <>The list of suggested tags will contain those <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 form" className="h-100">
|
||||
<FormGroup>
|
||||
<ToggleSwitch
|
||||
checked={shortUrlCreation.validateUrls ?? false}
|
||||
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
|
||||
>
|
||||
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>
|
||||
<ToggleSwitch
|
||||
checked={shortUrlCreation.forwardQuery ?? true}
|
||||
onChange={(forwardQuery) => setShortUrlCreationSettings({ ...shortUrlCreation, forwardQuery })}
|
||||
>
|
||||
Make all new short URLs forward their query params to the long URL.
|
||||
<small className="form-text text-muted">
|
||||
The initial state of the <b>Forward query params on redirect</b> checkbox will
|
||||
be <b>{shortUrlCreation.forwardQuery ?? true ? '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>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ export const Visits: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
|
||||
<FormGroup className="mb-0">
|
||||
<label>Default interval to load on visits sections:</label>
|
||||
<DateIntervalSelector
|
||||
allText="All visits"
|
||||
active={settings.visits?.defaultInterval ?? 'last30Days'}
|
||||
onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
|
||||
/>
|
||||
|
||||
@@ -12,17 +12,24 @@ 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;
|
||||
forwardQuery?: boolean;
|
||||
}
|
||||
|
||||
export type TagsMode = 'cards' | 'list';
|
||||
|
||||
export interface UiSettings {
|
||||
theme: Theme;
|
||||
tagsMode?: TagsMode;
|
||||
}
|
||||
|
||||
export interface VisitsSettings {
|
||||
|
||||
@@ -30,6 +30,7 @@ const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => (
|
||||
maxVisits: undefined,
|
||||
findIfExists: false,
|
||||
validateUrl: settings?.validateUrls ?? false,
|
||||
forwardQuery: settings?.forwardQuery ?? true,
|
||||
});
|
||||
|
||||
const CreateShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>, CreateShortUrlResult: FC<CreateShortUrlResultProps>) => ({
|
||||
|
||||
@@ -41,6 +41,8 @@ const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSetting
|
||||
validSince: shortUrl.meta.validSince ?? undefined,
|
||||
validUntil: shortUrl.meta.validUntil ?? undefined,
|
||||
maxVisits: shortUrl.meta.maxVisits ?? undefined,
|
||||
crawlable: shortUrl.crawlable,
|
||||
forwardQuery: shortUrl.forwardQuery,
|
||||
validateUrl,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||
import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination';
|
||||
import {
|
||||
pageIsEllipsis,
|
||||
keyForPage,
|
||||
progressivePagination,
|
||||
prettifyPageNumber,
|
||||
NumberOrEllipsis,
|
||||
} from '../utils/helpers/pagination';
|
||||
import { ShlinkPaginator } from '../api/types';
|
||||
import './Paginator.scss';
|
||||
|
||||
interface PaginatorProps {
|
||||
paginator?: ShlinkPaginator;
|
||||
serverId: string;
|
||||
currentQueryString?: string;
|
||||
}
|
||||
|
||||
const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
||||
const Paginator = ({ paginator, serverId, currentQueryString = '' }: PaginatorProps) => {
|
||||
const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
|
||||
const urlForPage = (pageNumber: NumberOrEllipsis) =>
|
||||
`/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`;
|
||||
|
||||
if (pagesCount <= 1) {
|
||||
return null;
|
||||
@@ -23,31 +31,20 @@ const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
||||
disabled={pageIsEllipsis(pageNumber)}
|
||||
active={currentPage === pageNumber}
|
||||
>
|
||||
<PaginationLink
|
||||
tag={Link}
|
||||
to={`/server/${serverId}/list-short-urls/${pageNumber}`}
|
||||
>
|
||||
<PaginationLink tag={Link} to={urlForPage(pageNumber)}>
|
||||
{prettifyPageNumber(pageNumber)}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
));
|
||||
|
||||
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
|
||||
tag={Link}
|
||||
to={`/server/${serverId}/list-short-urls/${currentPage - 1}`}
|
||||
/>
|
||||
<PaginationLink previous tag={Link} to={urlForPage(currentPage - 1)} />
|
||||
</PaginationItem>
|
||||
{renderPages()}
|
||||
<PaginationItem disabled={currentPage >= pagesCount}>
|
||||
<PaginationLink
|
||||
next
|
||||
tag={Link}
|
||||
to={`/server/${serverId}/list-short-urls/${currentPage + 1}`}
|
||||
/>
|
||||
<PaginationLink next tag={Link} to={urlForPage(currentPage + 1)} />
|
||||
</PaginationItem>
|
||||
</Pagination>
|
||||
);
|
||||
|
||||
@@ -1,40 +1,44 @@
|
||||
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 { RouteChildrenProps } from 'react-router-dom';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import Tag from '../tags/helpers/Tag';
|
||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||
import { formatIsoDate } from '../utils/helpers/date';
|
||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||
import { DateRange } from '../utils/dates/types';
|
||||
import { ShortUrlsListParams } from './reducers/shortUrlsListParams';
|
||||
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
|
||||
import './SearchBar.scss';
|
||||
|
||||
interface SearchBarProps {
|
||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
||||
shortUrlsListParams: ShortUrlsListParams;
|
||||
}
|
||||
export type SearchBarProps = RouteChildrenProps<ShortUrlListRouteParams>;
|
||||
|
||||
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 ?? [];
|
||||
const SearchBar = (colorGenerator: ColorGenerator) => (props: SearchBarProps) => {
|
||||
const [{ search, tags, startDate, endDate }, toFirstPage ] = useShortUrlsQuery(props);
|
||||
const selectedTags = tags?.split(',') ?? [];
|
||||
const setDates = pipe(
|
||||
({ startDate, endDate }: DateRange) => ({
|
||||
startDate: formatIsoDate(startDate) ?? undefined,
|
||||
endDate: formatIsoDate(endDate) ?? undefined,
|
||||
}),
|
||||
(dates) => listShortUrls({ ...shortUrlsListParams, ...dates }),
|
||||
toFirstPage,
|
||||
);
|
||||
const setSearch = pipe(
|
||||
(searchTerm: string) => isEmpty(searchTerm) ? undefined : searchTerm,
|
||||
(search) => toFirstPage({ search }),
|
||||
);
|
||||
const removeTag = pipe(
|
||||
(tag: string) => selectedTags.filter((selectedTag) => selectedTag !== tag),
|
||||
(tagsList) => tagsList.length === 0 ? undefined : tagsList.join(','),
|
||||
(tags) => toFirstPage({ tags }),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="search-bar-container">
|
||||
<SearchField
|
||||
onChange={
|
||||
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
||||
}
|
||||
/>
|
||||
<SearchField initialValue={search} onChange={setSearch} />
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="row">
|
||||
@@ -42,8 +46,8 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl
|
||||
<DateRangeSelector
|
||||
defaultText="All short URLs"
|
||||
initialDateRange={{
|
||||
startDate: dateOrNull(shortUrlsListParams.startDate),
|
||||
endDate: dateOrNull(shortUrlsListParams.endDate),
|
||||
startDate: dateOrNull(startDate),
|
||||
endDate: dateOrNull(endDate),
|
||||
}}
|
||||
onDatesChange={setDates}
|
||||
/>
|
||||
@@ -51,24 +55,12 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEmpty(selectedTags) && (
|
||||
{selectedTags.length > 0 && (
|
||||
<h4 className="search-bar__selected-tag mt-3">
|
||||
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
||||
|
||||
{selectedTags.map((tag) => (
|
||||
<Tag
|
||||
colorGenerator={colorGenerator}
|
||||
key={tag}
|
||||
text={tag}
|
||||
clearable
|
||||
onClose={() => listShortUrls(
|
||||
{
|
||||
...shortUrlsListParams,
|
||||
tags: selectedTags.filter((selectedTag) => selectedTag !== tag),
|
||||
},
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{selectedTags.map((tag) =>
|
||||
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
|
||||
</h4>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
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 { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
|
||||
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, supportsForwardQuery, supportsShortUrlTitle } from '../utils/helpers/features';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
|
||||
import { handleEventPreventingDefault, hasValue, OptionalString } from '../utils/utils';
|
||||
import Checkbox from '../utils/Checkbox';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||
@@ -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,21 +32,34 @@ export interface ShortUrlFormProps {
|
||||
}
|
||||
|
||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||
const toDate = (date?: string | Date): Date | undefined => typeof date === 'string' ? parseISO(date) : date;
|
||||
const dynamicColClasses = (flag: boolean) => ({ 'col-sm-6': flag, 'col-sm-12': !flag });
|
||||
|
||||
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 hadTitleOriginally = hasValue(initialState.title);
|
||||
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
|
||||
const reset = () => setShortUrlData(initialState);
|
||||
const resolveNewTitle = (): OptionalString => {
|
||||
const hasNewTitle = hasValue(shortUrlData.title);
|
||||
const matcher = cond<never, OptionalString>([
|
||||
[ () => !hasNewTitle && !hadTitleOriginally, () => undefined ],
|
||||
[ () => !hasNewTitle && hadTitleOriginally, () => null ],
|
||||
[ T, () => shortUrlData.title ],
|
||||
]);
|
||||
|
||||
return matcher();
|
||||
};
|
||||
const submit = handleEventPreventingDefault(async () => onSave({
|
||||
...shortUrlData,
|
||||
validSince: formatIsoDate(shortUrlData.validSince) ?? null,
|
||||
validUntil: formatIsoDate(shortUrlData.validUntil) ?? null,
|
||||
maxVisits: !hasValue(shortUrlData.maxVisits) ? null : Number(shortUrlData.maxVisits),
|
||||
title: !hasValue(shortUrlData.title) ? undefined : shortUrlData.title,
|
||||
title: resolveNewTitle(),
|
||||
}).then(() => !isEdit && reset()).catch(() => {}));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -72,7 +81,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 +103,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 limitAccessCardClasses = classNames('mb-3', dynamicColClasses(showCustomizeCard));
|
||||
const showCrawlableControl = supportsCrawlableVisits(selectedServer);
|
||||
const showForwardQueryControl = supportsForwardQuery(selectedServer);
|
||||
const showBehaviorCard = showCrawlableControl || showForwardQueryControl;
|
||||
const extraChecksCardClasses = classNames('mb-3', dynamicColClasses(showBehaviorCard));
|
||||
|
||||
return (
|
||||
<form className="short-url-form" onSubmit={submit}>
|
||||
@@ -135,22 +141,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 +160,62 @@ 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>
|
||||
)}
|
||||
<Row>
|
||||
<div className={extraChecksCardClasses}>
|
||||
<SimpleCard title="Extra checks">
|
||||
<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>
|
||||
{!isEdit && (
|
||||
<p>
|
||||
<Checkbox
|
||||
inline
|
||||
className="mr-2"
|
||||
checked={shortUrlData.findIfExists}
|
||||
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
|
||||
>
|
||||
Use existing URL if found
|
||||
</Checkbox>
|
||||
<UseExistingIfFoundInfoIcon />
|
||||
</p>
|
||||
)}
|
||||
</SimpleCard>
|
||||
</div>
|
||||
{showBehaviorCard && (
|
||||
<div className="col-sm-6 mb-3">
|
||||
<SimpleCard title="Configure behavior">
|
||||
{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>
|
||||
)}
|
||||
{showForwardQueryControl && (
|
||||
<ShortUrlFormCheckboxGroup
|
||||
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
|
||||
checked={shortUrlData.forwardQuery}
|
||||
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
|
||||
>
|
||||
Forward query params on redirect
|
||||
</ShortUrlFormCheckboxGroup>
|
||||
)}
|
||||
</SimpleCard>
|
||||
</div>
|
||||
)}
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { ShortUrlsListProps } from './ShortUrlsList';
|
||||
|
||||
const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps>) => (props: ShortUrlsListProps) => {
|
||||
const { match } = props;
|
||||
const { page = '1', serverId = '' } = match?.params ?? {};
|
||||
const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`);
|
||||
|
||||
// Using a key on a component makes react to create a new instance every time the key changes
|
||||
// Without it, pagination on the URL will not make the component to be refreshed
|
||||
useEffect(() => {
|
||||
setUrlsListKey(`${serverId}_${page}`);
|
||||
}, [ serverId, page ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-group"><SearchBar /></div>
|
||||
<ShortUrlsList {...props} key={urlsListKey} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortUrls;
|
||||
@@ -1,3 +0,0 @@
|
||||
.short-urls-list__header-icon {
|
||||
margin-left: .4rem;
|
||||
}
|
||||
@@ -1,27 +1,21 @@
|
||||
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { head, keys, values } from 'ramda';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { head, keys, pipe, values } from 'ramda';
|
||||
import { FC, useEffect, useMemo, useState } from 'react';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
import { Card } from 'reactstrap';
|
||||
import SortingDropdown from '../utils/SortingDropdown';
|
||||
import { determineOrderDir, OrderDir } from '../utils/utils';
|
||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||
import { determineOrderDir, Order, OrderDir } from '../utils/helpers/ordering';
|
||||
import { getServerId, SelectedServer } from '../servers/data';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { parseQuery } from '../utils/helpers/query';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||
import { ShlinkShortUrlsListParams } from '../api/types';
|
||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
|
||||
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
||||
import Paginator from './Paginator';
|
||||
import './ShortUrlsList.scss';
|
||||
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
|
||||
|
||||
interface RouteParams {
|
||||
page: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export interface ShortUrlsListProps extends RouteComponentProps<RouteParams> {
|
||||
interface ShortUrlsListProps extends RouteComponentProps<ShortUrlListRouteParams> {
|
||||
selectedServer: SelectedServer;
|
||||
shortUrlsList: ShortUrlsListState;
|
||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
||||
@@ -29,76 +23,68 @@ export interface ShortUrlsListProps extends RouteComponentProps<RouteParams> {
|
||||
resetShortUrlParams: () => void;
|
||||
}
|
||||
|
||||
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercureHub(({
|
||||
type ShortUrlsOrder = Order<OrderableFields>;
|
||||
|
||||
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, SearchBar: FC) => boundToMercureHub(({
|
||||
listShortUrls,
|
||||
resetShortUrlParams,
|
||||
shortUrlsListParams,
|
||||
match,
|
||||
location,
|
||||
history,
|
||||
shortUrlsList,
|
||||
selectedServer,
|
||||
}: ShortUrlsListProps) => {
|
||||
const serverId = getServerId(selectedServer);
|
||||
const { orderBy } = shortUrlsListParams;
|
||||
const [ order, setOrder ] = useState<{ orderField?: OrderableFields; orderDir?: OrderDir }>({
|
||||
orderField: orderBy && (head(keys(orderBy)) as OrderableFields),
|
||||
orderDir: orderBy && head(values(orderBy)),
|
||||
const [ order, setOrder ] = useState<ShortUrlsOrder>({
|
||||
field: orderBy && (head(keys(orderBy)) as OrderableFields),
|
||||
dir: orderBy && head(values(orderBy)),
|
||||
});
|
||||
const [{ tags, search, startDate, endDate }, toFirstPage ] = useShortUrlsQuery({ history, match, location });
|
||||
const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]);
|
||||
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
||||
const refreshList = (extraParams: ShortUrlsListParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
|
||||
const handleOrderBy = (orderField?: OrderableFields, orderDir?: OrderDir) => {
|
||||
setOrder({ orderField, orderDir });
|
||||
refreshList({ orderBy: orderField ? { [orderField]: orderDir } : undefined });
|
||||
|
||||
const refreshList = (extraParams: ShlinkShortUrlsListParams) => listShortUrls(
|
||||
{ ...shortUrlsListParams, ...extraParams },
|
||||
);
|
||||
const handleOrderBy = (field?: OrderableFields, dir?: OrderDir) => {
|
||||
setOrder({ field, dir });
|
||||
refreshList({ orderBy: field ? { [field]: dir } : undefined });
|
||||
};
|
||||
const orderByColumn = (field: OrderableFields) => () =>
|
||||
handleOrderBy(field, determineOrderDir(field, order.orderField, order.orderDir));
|
||||
const renderOrderIcon = (field: OrderableFields) => {
|
||||
if (order.orderField !== field) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!order.orderDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FontAwesomeIcon
|
||||
icon={order.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
|
||||
className="short-urls-list__header-icon"
|
||||
/>
|
||||
);
|
||||
};
|
||||
handleOrderBy(field, determineOrderDir(field, order.field, order.dir));
|
||||
const renderOrderIcon = (field: OrderableFields) => <TableOrderIcon currentOrder={order} field={field} />;
|
||||
const addTag = pipe(
|
||||
(newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','),
|
||||
(tags) => toFirstPage({ tags }),
|
||||
);
|
||||
|
||||
useEffect(() => resetShortUrlParams, []);
|
||||
useEffect(() => {
|
||||
const { tag } = parseQuery<{ tag?: string }>(location.search);
|
||||
const tags = tag ? [ tag ] : shortUrlsListParams.tags;
|
||||
|
||||
refreshList({ page: match.params.page, tags, itemsPerPage: undefined });
|
||||
|
||||
return resetShortUrlParams;
|
||||
}, []);
|
||||
refreshList(
|
||||
{ page: match.params.page, searchTerm: search, tags: selectedTags, itemsPerPage: undefined, startDate, endDate },
|
||||
);
|
||||
}, [ match.params.page, search, selectedTags, startDate, endDate ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3"><SearchBar /></div>
|
||||
<div className="d-block d-lg-none mb-3">
|
||||
<SortingDropdown
|
||||
items={SORTABLE_FIELDS}
|
||||
orderField={order.orderField}
|
||||
orderDir={order.orderDir}
|
||||
onChange={handleOrderBy}
|
||||
/>
|
||||
<SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={handleOrderBy} />
|
||||
</div>
|
||||
<Card body className="pb-1">
|
||||
<ShortUrlsTable
|
||||
orderByColumn={orderByColumn}
|
||||
renderOrderIcon={renderOrderIcon}
|
||||
selectedServer={selectedServer}
|
||||
shortUrlsList={shortUrlsList}
|
||||
onTagClick={(tag) => refreshList({ tags: [ ...shortUrlsListParams.tags ?? [], tag ] })}
|
||||
orderByColumn={orderByColumn}
|
||||
renderOrderIcon={renderOrderIcon}
|
||||
onTagClick={addTag}
|
||||
/>
|
||||
<Paginator paginator={pagination} serverId={isReachableServer(selectedServer) ? selectedServer.id : ''} />
|
||||
<Paginator paginator={pagination} serverId={serverId} currentQueryString={location.search} />
|
||||
</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;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||
if (error) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center table-danger">Something went wrong while loading short URLs :(</td>
|
||||
<td colSpan={6} className="text-center table-danger text-dark">
|
||||
Something went wrong while loading short URLs :(
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -60,37 +62,32 @@ 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
|
||||
{renderOrderIcon?.('dateCreated')}
|
||||
Created at {renderOrderIcon?.('dateCreated')}
|
||||
</th>
|
||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
|
||||
Short URL
|
||||
{renderOrderIcon?.('shortCode')}
|
||||
Short URL {renderOrderIcon?.('shortCode')}
|
||||
</th>
|
||||
{!supportsTitle && (
|
||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}>
|
||||
Long URL
|
||||
{renderOrderIcon?.('longUrl')}
|
||||
Long URL {renderOrderIcon?.('longUrl')}
|
||||
</th>
|
||||
) || (
|
||||
<th className="short-urls-table__header-cell">
|
||||
<span className={actionableFieldClasses} onClick={orderByColumn?.('title')}>
|
||||
Title
|
||||
{renderOrderIcon?.('title')}
|
||||
Title {renderOrderIcon?.('title')}
|
||||
</span>
|
||||
/
|
||||
<span className={actionableFieldClasses} onClick={orderByColumn?.('longUrl')}>
|
||||
<span className="indivisible">Long URL</span>
|
||||
{renderOrderIcon?.('longUrl')}
|
||||
<span className="indivisible">Long URL</span> {renderOrderIcon?.('longUrl')}
|
||||
</span>
|
||||
</th>
|
||||
)}
|
||||
<th className="short-urls-table__header-cell">Tags</th>
|
||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
|
||||
<span className="indivisible">Visits{renderOrderIcon?.('visits')}</span>
|
||||
<span className="indivisible">Visits {renderOrderIcon?.('visits')}</span>
|
||||
</th>
|
||||
<th className="short-urls-table__header-cell"> </th>
|
||||
</tr>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
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;
|
||||
title?: string | null;
|
||||
validSince?: Date | string | null;
|
||||
validUntil?: Date | string | null;
|
||||
maxVisits?: number | null;
|
||||
validateUrl?: boolean;
|
||||
crawlable?: boolean;
|
||||
forwardQuery?: boolean;
|
||||
}
|
||||
|
||||
export interface ShortUrlData extends EditShortUrlData {
|
||||
@@ -29,6 +30,8 @@ export interface ShortUrl {
|
||||
tags: string[];
|
||||
domain: string | null;
|
||||
title?: string | null;
|
||||
crawlable?: boolean;
|
||||
forwardQuery?: 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,19 +0,0 @@
|
||||
@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;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: $dangerColor !important;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
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';
|
||||
|
||||
export interface ShortUrlsRowMenuProps {
|
||||
selectedServer: SelectedServer;
|
||||
@@ -29,32 +28,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="dropdown-item--danger" onClick={toggleDelete}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||
</DropdownItem>
|
||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
|
||||
</DropdownBtnMenu>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
31
src/short-urls/helpers/hooks.ts
Normal file
31
src/short-urls/helpers/hooks.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { RouteChildrenProps } from 'react-router-dom';
|
||||
import { useMemo } from 'react';
|
||||
import { isEmpty } from 'ramda';
|
||||
import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
|
||||
|
||||
type ServerIdRouteProps = RouteChildrenProps<{ serverId: string }>;
|
||||
type ToFirstPage = (extra: Partial<ShortUrlsQuery>) => void;
|
||||
|
||||
export interface ShortUrlListRouteParams {
|
||||
page: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
interface ShortUrlsQuery {
|
||||
tags?: string;
|
||||
search?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export const useShortUrlsQuery = ({ history, location, match }: ServerIdRouteProps): [ShortUrlsQuery, ToFirstPage] => {
|
||||
const query = useMemo(() => parseQuery<ShortUrlsQuery>(location.search), [ location ]);
|
||||
const toFirstPageWithExtra = (extra: Partial<ShortUrlsQuery>) => {
|
||||
const evolvedQuery = stringifyQuery({ ...query, ...extra });
|
||||
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;
|
||||
|
||||
history.push(`/server/${match?.params.serverId}/list-short-urls/1${queryString}`);
|
||||
};
|
||||
|
||||
return [ query, toFirstPageWithExtra ];
|
||||
};
|
||||
@@ -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 }),
|
||||
@@ -52,8 +49,8 @@ export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
||||
const result = await createShortUrl(data);
|
||||
|
||||
dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result });
|
||||
} catch (e) {
|
||||
dispatch<CreateShortUrlFailedAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
} catch (e: any) {
|
||||
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 }),
|
||||
@@ -51,8 +48,8 @@ export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
||||
try {
|
||||
await deleteShortUrl(shortCode, domain);
|
||||
dispatch<DeleteShortUrlAction>({ type: SHORT_URL_DELETED, shortCode, domain });
|
||||
} catch (e) {
|
||||
dispatch<DeleteShortUrlErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
} catch (e: any) {
|
||||
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 }),
|
||||
@@ -53,7 +50,7 @@ export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder)
|
||||
) ?? await buildShlinkApiClient(getState).getShortUrl(shortCode, domain);
|
||||
|
||||
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });
|
||||
} catch (e) {
|
||||
dispatch<ShortUrlDetailFailedAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
|
||||
} catch (e: any) {
|
||||
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 }),
|
||||
@@ -58,8 +55,8 @@ export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||
]);
|
||||
|
||||
dispatch<ShortUrlEditedAction>({ shortUrl, type: SHORT_URL_EDITED });
|
||||
} catch (e) {
|
||||
dispatch<ShortUrlEditionFailedAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
} catch (e: any) {
|
||||
dispatch<ApiErrorAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCr
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { GetState } from '../../container/types';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ShlinkShortUrlsResponse } from '../../api/types';
|
||||
import { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types';
|
||||
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
|
||||
import { ShortUrlsListParams } from './shortUrlsListParams';
|
||||
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
||||
@@ -101,7 +101,7 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
|
||||
}, initialState);
|
||||
|
||||
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||
params: ShortUrlsListParams = {},
|
||||
params: ShlinkShortUrlsListParams = {},
|
||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||
dispatch({ type: LIST_SHORT_URLS_START });
|
||||
const { listShortUrls } = buildShlinkApiClient(getState);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||
import { OrderDir } from '../../utils/utils';
|
||||
import { OrderDir } from '../../utils/helpers/ordering';
|
||||
import { LIST_SHORT_URLS, ListShortUrlsAction } from './shortUrlsList';
|
||||
|
||||
export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS';
|
||||
@@ -14,14 +14,12 @@ export const SORTABLE_FIELDS = {
|
||||
|
||||
export type OrderableFields = keyof typeof SORTABLE_FIELDS;
|
||||
|
||||
export type OrderBy = Partial<Record<OrderableFields, OrderDir>>;
|
||||
|
||||
export interface ShortUrlsListParams {
|
||||
page?: string;
|
||||
itemsPerPage?: number;
|
||||
tags?: string[];
|
||||
searchTerm?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
orderBy?: Partial<Record<OrderableFields, OrderDir>>;
|
||||
orderBy?: OrderBy;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlsListParams = {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Bottle from 'bottlejs';
|
||||
import ShortUrls from '../ShortUrls';
|
||||
import Bottle, { Decorator } from 'bottlejs';
|
||||
import SearchBar from '../SearchBar';
|
||||
import ShortUrlsList from '../ShortUrlsList';
|
||||
import ShortUrlsRow from '../helpers/ShortUrlsRow';
|
||||
@@ -19,14 +18,11 @@ import { ShortUrlForm } from '../ShortUrlForm';
|
||||
import { EditShortUrl } from '../EditShortUrl';
|
||||
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory('ShortUrls', ShortUrls, 'SearchBar', 'ShortUrlsList');
|
||||
bottle.decorator('ShortUrls', connect([ 'shortUrlsList' ]));
|
||||
|
||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable');
|
||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'SearchBar');
|
||||
bottle.decorator('ShortUrlsList', connect(
|
||||
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ],
|
||||
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo', 'shortUrlsList' ],
|
||||
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ],
|
||||
));
|
||||
|
||||
@@ -51,12 +47,12 @@ 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
|
||||
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
|
||||
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
|
||||
bottle.decorator('SearchBar', withRouter);
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
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 { getServerId, SelectedServer } from '../servers/data';
|
||||
import TagBullet from './helpers/TagBullet';
|
||||
import { TagModalProps, TagStats } from './data';
|
||||
import { NormalizedTag, TagModalProps } from './data';
|
||||
import './TagCard.scss';
|
||||
|
||||
export interface TagCardProps {
|
||||
tag: string;
|
||||
tagStats?: TagStats;
|
||||
tag: NormalizedTag;
|
||||
selectedServer: SelectedServer;
|
||||
displayed: boolean;
|
||||
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) => {
|
||||
) => ({ tag, selectedServer, displayed, toggle }: TagCardProps) => {
|
||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||
const [ hasTitle,, displayTitle ] = useToggle();
|
||||
const titleRef = useRef<HTMLElement>();
|
||||
const serverId = getServerId(selectedServer);
|
||||
|
||||
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,40 +46,39 @@ 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">
|
||||
<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>
|
||||
<h5
|
||||
className="tag-card__tag-title text-ellipsis"
|
||||
title={hasTitle ? tag.tag : undefined}
|
||||
ref={(el) => {
|
||||
titleRef.current = el ?? undefined;
|
||||
}}
|
||||
>
|
||||
<TagBullet tag={tag.tag} colorGenerator={colorGenerator} />
|
||||
<span className="tag-card__tag-name" onClick={toggle}>{tag.tag}</span>
|
||||
</h5>
|
||||
</CardHeader>
|
||||
|
||||
{tagStats && (
|
||||
<Collapse isOpen={displayed}>
|
||||
<CardBody className="tag-card__body">
|
||||
<Link
|
||||
to={shortUrlsLink}
|
||||
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>
|
||||
<b>{prettify(tagStats.shortUrlsCount)}</b>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/server/${serverId}/tag/${tag}/visits`}
|
||||
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
|
||||
<b>{prettify(tagStats.visitsCount)}</b>
|
||||
</Link>
|
||||
</CardBody>
|
||||
</Collapse>
|
||||
)}
|
||||
<Collapse isOpen={displayed}>
|
||||
<CardBody className="tag-card__body">
|
||||
<Link
|
||||
to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.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>
|
||||
<b>{prettify(tag.shortUrls)}</b>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/server/${serverId}/tag/${tag.tag}/visits`}
|
||||
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
|
||||
<b>{prettify(tag.visits)}</b>
|
||||
</Link>
|
||||
</CardBody>
|
||||
</Collapse>
|
||||
|
||||
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
||||
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
||||
<DeleteTagConfirmModal tag={tag.tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
||||
<EditTagModal tag={tag.tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
32
src/tags/TagsCards.tsx
Normal file
32
src/tags/TagsCards.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
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> => ({ sortedTags, selectedServer }) => {
|
||||
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
|
||||
const tagsCount = sortedTags.length;
|
||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), sortedTags);
|
||||
|
||||
return (
|
||||
<Row>
|
||||
{tagsGroups.map((group, index) => (
|
||||
<div key={index} className="col-md-6 col-xl-3">
|
||||
{group.map((tag) => (
|
||||
<TagCard
|
||||
key={tag.tag}
|
||||
tag={tag}
|
||||
selectedServer={selectedServer}
|
||||
displayed={displayedTag === tag.tag}
|
||||
toggle={() => setDisplayedTag(displayedTag !== tag.tag ? tag.tag : undefined)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { splitEvery } from 'ramda';
|
||||
import { Row } from 'reactstrap';
|
||||
import { pipe } from 'ramda';
|
||||
import Message from '../utils/Message';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
@@ -7,75 +8,92 @@ 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 { determineOrderDir, sortList } from '../utils/helpers/ordering';
|
||||
import SortingDropdown from '../utils/SortingDropdown';
|
||||
import { TagsList as TagsListState } from './reducers/tagsList';
|
||||
import { TagCardProps } from './TagCard';
|
||||
|
||||
const { ceil } = Math;
|
||||
const TAGS_GROUPS_AMOUNT = 4;
|
||||
import { OrderableFields, SORTABLE_FIELDS, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps';
|
||||
import { TagsModeDropdown } from './TagsModeDropdown';
|
||||
import { NormalizedTag } from './data';
|
||||
import { TagsTableProps } from './TagsTable';
|
||||
|
||||
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<TagsTableProps>) => boundToMercureHub((
|
||||
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
|
||||
) => {
|
||||
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
|
||||
const [ mode, setMode ] = useState<TagsMode>(settings.ui?.tagsMode ?? 'cards');
|
||||
const [ order, setOrder ] = useState<TagsOrder>({});
|
||||
const resolveSortedTags = pipe(
|
||||
() => tagsList.filteredTags.map((tag): NormalizedTag => ({
|
||||
tag,
|
||||
shortUrls: tagsList.stats[tag]?.shortUrlsCount ?? 0,
|
||||
visits: tagsList.stats[tag]?.visitsCount ?? 0,
|
||||
})),
|
||||
(normalizedTags) => sortList<NormalizedTag>(normalizedTags, order),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
forceListTags();
|
||||
}, []);
|
||||
|
||||
if (tagsList.loading) {
|
||||
return <Message loading />;
|
||||
}
|
||||
|
||||
if (tagsList.error) {
|
||||
return (
|
||||
<Result type="error">
|
||||
<ShlinkApiError errorData={tagsList.errorData} fallbackMessage="Error loading tags :(" />
|
||||
</Result>
|
||||
);
|
||||
}
|
||||
|
||||
const orderByColumn = (field: OrderableFields) => () => {
|
||||
const dir = determineOrderDir(field, order.field, order.dir);
|
||||
|
||||
setOrder({ field: dir ? field : undefined, dir });
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (tagsList.loading) {
|
||||
return <Message loading />;
|
||||
}
|
||||
|
||||
if (tagsList.error) {
|
||||
return (
|
||||
<Result type="error">
|
||||
<ShlinkApiError errorData={tagsList.errorData} fallbackMessage="Error loading tags :(" />
|
||||
</Result>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
const sortedTags = resolveSortedTags();
|
||||
|
||||
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 sortedTags={sortedTags} selectedServer={selectedServer} />
|
||||
: (
|
||||
<TagsTable
|
||||
sortedTags={sortedTags}
|
||||
selectedServer={selectedServer}
|
||||
currentOrder={order}
|
||||
orderByColumn={orderByColumn}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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">
|
||||
<TagsModeDropdown mode={mode} onChange={setMode} />
|
||||
</div>
|
||||
<div className="col-lg-6 mt-3 mt-lg-0">
|
||||
<SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={(field, dir) => setOrder({ field, dir })} />
|
||||
</div>
|
||||
</Row>
|
||||
{renderContent()}
|
||||
</>
|
||||
);
|
||||
}, () => [ Topics.visits() ]);
|
||||
}, () => [ Topics.visits ]);
|
||||
|
||||
export default TagsList;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user