mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-27 04:06:39 +00:00
Compare commits
178 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff1821666e | ||
|
|
9c6c1b43c8 | ||
|
|
4986dbcb91 | ||
|
|
527d4acf17 | ||
|
|
0237253caf | ||
|
|
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 | ||
|
|
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 |
@@ -5,3 +5,4 @@
|
|||||||
./test
|
./test
|
||||||
./shlink-web-client.gif
|
./shlink-web-client.gif
|
||||||
./dist
|
./dist
|
||||||
|
./docs
|
||||||
|
|||||||
@@ -14,5 +14,9 @@
|
|||||||
"process": true,
|
"process": true,
|
||||||
"setImmediate": 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:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- develop
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
ci:
|
||||||
runs-on: ubuntu-20.04
|
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
|
||||||
steps:
|
with:
|
||||||
- name: Checkout code
|
node-version: 16.13
|
||||||
uses: actions/checkout@v2
|
with-mutation-tests: true
|
||||||
- name: Use node.js 14.15
|
publish-coverage: true
|
||||||
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 .
|
|
||||||
|
|||||||
22
.github/workflows/deploy-preview.yml
vendored
22
.github/workflows/deploy-preview.yml
vendored
@@ -13,29 +13,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
ref: ${{ github.event.pull_request.head.ref }}
|
||||||
- name: Use node.js 14.15
|
- name: Use node.js
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 14.15
|
node-version: 16.13
|
||||||
- name: Generate slug
|
|
||||||
id: generate_slug
|
|
||||||
run: echo "##[set-output name=slug;]$(echo ${GITHUB_HEAD_REF#refs/heads/} | sed -r 's/[~\^]+//g' | sed -r 's/[^a-zA-Z0-9]+/-/g' | sed -r 's/^-+\|-+$//g' | tr A-Z a-z)"
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
npm ci && \
|
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 && \
|
rm src/service-worker.ts && \
|
||||||
npm run build
|
npm run build
|
||||||
- name: Deploy
|
- name: Deploy preview
|
||||||
uses: JamesIves/github-pages-deploy-action@4.1.1
|
uses: shlinkio/deploy-preview-action@v1.0.1
|
||||||
with:
|
with:
|
||||||
branch: preview-env
|
|
||||||
folder: build
|
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 }}/
|
|
||||||
|
|||||||
4
.github/workflows/publish-release.yml
vendored
4
.github/workflows/publish-release.yml
vendored
@@ -11,10 +11,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
- name: Use node.js 14.15
|
- name: Use node.js
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 14.15
|
node-version: 16.13
|
||||||
- name: Generate release assets
|
- name: Generate release assets
|
||||||
run: npm ci && npm run build ${GITHUB_REF#refs/tags/v}
|
run: npm ci && npm run build ${GITHUB_REF#refs/tags/v}
|
||||||
- name: Publish release with assets
|
- name: Publish release with assets
|
||||||
|
|||||||
150
CHANGELOG.md
150
CHANGELOG.md
@@ -4,6 +4,132 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [3.4.2] - 2021-12-07
|
||||||
|
### Added
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* [#530](https://github.com/shlinkio/shlink-web-client/issues/530) Fixed crash on domains page when default domain has an explicitly set port.
|
||||||
|
|
||||||
|
|
||||||
|
## [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
|
## [3.2.1] - 2021-09-12
|
||||||
### Added
|
### Added
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
@@ -18,9 +144,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
* [#478](https://github.com/shlinkio/shlink-web-client/pull/478) Fixed tags including special chars not being properly URL encoded before using them as query params.
|
* [#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/pull/480) Fixed servers import on Chromium-based browsers when using windows.
|
* [#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/pull/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).
|
* [#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
|
## [3.2.0] - 2021-07-12
|
||||||
@@ -32,16 +158,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
* `SHLINK_SERVER_NAME`: A name you want to give to this server. Defaults to *Shlink* if not provided.
|
* `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.
|
* [#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/pull/440) Added hint of what visits come potentially from a bot, in the visits table, when consuming Shlink >=2.7.
|
* [#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/pull/431) Added support to filter out visits from potential bots in visits sections, 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/pull/430) Added support to set new and existing short URLs as crawlable, 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/pull/450) Improved landing page design.
|
* [#450](https://github.com/shlinkio/shlink-web-client/issues/450) Improved landing page design.
|
||||||
* [#449](https://github.com/shlinkio/shlink-web-client/pull/449) Improved PWA update banner, allowing to restart the app directly from it without having to close the tab.
|
* [#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
|
### Changed
|
||||||
* [#442](https://github.com/shlinkio/shlink-web-client/pull/442) Visits filtering now goes through the corresponding reducer.
|
* [#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/pull/337) Replaced moment.js with date-fns.
|
* [#337](https://github.com/shlinkio/shlink-web-client/issues/337) Replaced moment.js with date-fns.
|
||||||
* [#360](https://github.com/shlinkio/shlink-web-client/pull/360) Changed component used to generate a tags selector, switching from `react-tagsinput`, which is no longer maintained, to `react-tag-autocomplete`.
|
* [#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
|
### Deprecated
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
@@ -50,7 +176,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
* [#438](https://github.com/shlinkio/shlink-web-client/pull/438) Fixed horizontal scrolling in short URLs list on mobile devices when the long URL didn't have words to break.
|
* [#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
|
## [3.1.2] - 2021-06-06
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:14.17-alpine as node
|
FROM node:16.13-alpine as node
|
||||||
COPY . /shlink-web-client
|
COPY . /shlink-web-client
|
||||||
ARG VERSION="latest"
|
ARG VERSION="latest"
|
||||||
ENV VERSION ${VERSION}
|
ENV VERSION ${VERSION}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# shlink-web-client
|
# shlink-web-client
|
||||||
|
|
||||||
[](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
|
[](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
|
||||||
[](https://app.codecov.io/gh/shlinkio/shlink-web-client)
|
[](https://app.codecov.io/gh/shlinkio/shlink-web-client)
|
||||||
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||||
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||||
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ version: '3'
|
|||||||
services:
|
services:
|
||||||
shlink_web_client_node:
|
shlink_web_client_node:
|
||||||
container_name: shlink_web_client_node
|
container_name: shlink_web_client_node
|
||||||
image: node:14.17-alpine
|
image: node:16.13-alpine
|
||||||
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/home/shlink/www
|
- ./:/home/shlink/www
|
||||||
|
|||||||
@@ -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 = {
|
module.exports = {
|
||||||
coverageDirectory: '<rootDir>/coverage',
|
coverageDirectory: '<rootDir>/coverage',
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
'src/**/*.{js,ts,tsx}',
|
'src/**/*.{ts,tsx}',
|
||||||
'!src/registerServiceWorker.js',
|
'!src/*.{ts,tsx}',
|
||||||
'!src/index.ts',
|
|
||||||
'!src/reducers/index.ts',
|
'!src/reducers/index.ts',
|
||||||
'!src/**/provideServices.ts',
|
'!src/**/provideServices.ts',
|
||||||
'!src/container/*.ts',
|
'!src/container/*.ts',
|
||||||
],
|
],
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
statements: 85,
|
||||||
|
branches: 80,
|
||||||
|
functions: 80,
|
||||||
|
lines: 85,
|
||||||
|
},
|
||||||
|
},
|
||||||
resolver: 'jest-pnp-resolver',
|
resolver: 'jest-pnp-resolver',
|
||||||
setupFiles: [
|
setupFiles: [
|
||||||
'react-app-polyfill/jsdom',
|
'react-app-polyfill/jsdom',
|
||||||
|
|||||||
42681
package-lock.json
generated
42681
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
39
package.json
39
package.json
@@ -7,16 +7,18 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "npm run lint:css && npm run lint:js",
|
"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: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:css:fix": "npm run lint:css -- --fix",
|
||||||
|
"lint:js:fix": "npm run lint:js -- --fix",
|
||||||
"start": "node scripts/start.js",
|
"start": "node scripts/start.js",
|
||||||
"serve:build": "serve ./build",
|
"serve:build": "serve ./build",
|
||||||
"build": "node scripts/build.js",
|
"build": "node scripts/build.js",
|
||||||
"test": "node scripts/test.js --env=jsdom --colors --verbose",
|
"test": "node scripts/test.js --env=jsdom --colors --verbose",
|
||||||
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
|
||||||
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
|
"test:ci": "npm run test:coverage -- --coverageReporters=clover",
|
||||||
|
"test:pretty": "npm run test:coverage -- --coverageReporters=html",
|
||||||
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
|
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -29,7 +31,7 @@
|
|||||||
"bootstrap": "^4.6.0",
|
"bootstrap": "^4.6.0",
|
||||||
"bottlejs": "^2.0.0",
|
"bottlejs": "^2.0.0",
|
||||||
"bowser": "^2.11.0",
|
"bowser": "^2.11.0",
|
||||||
"chart.js": "^2.9.4",
|
"chart.js": "^3.5.1",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
"compare-versions": "^3.6.0",
|
"compare-versions": "^3.6.0",
|
||||||
"csvjson": "^5.1.0",
|
"csvjson": "^5.1.0",
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
"qs": "^6.9.6",
|
"qs": "^6.9.6",
|
||||||
"ramda": "^0.27.1",
|
"ramda": "^0.27.1",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
"react-chartjs-2": "^2.11.1",
|
"react-chartjs-2": "^3.0.4",
|
||||||
"react-color": "^2.19.3",
|
"react-color": "^2.19.3",
|
||||||
"react-copy-to-clipboard": "^5.0.2",
|
"react-copy-to-clipboard": "^5.0.2",
|
||||||
"react-datepicker": "^3.6.0",
|
"react-datepicker": "^3.6.0",
|
||||||
@@ -67,14 +69,13 @@
|
|||||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
|
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
|
||||||
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
|
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
|
||||||
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
|
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
|
||||||
"@stryker-mutator/core": "^5.0.0",
|
"@stryker-mutator/core": "^5.4.1",
|
||||||
"@stryker-mutator/jest-runner": "^5.0.0",
|
"@stryker-mutator/jest-runner": "^5.4.1",
|
||||||
"@stryker-mutator/typescript-checker": "^5.0.0",
|
"@stryker-mutator/typescript-checker": "^5.4.1",
|
||||||
"@svgr/webpack": "^5.5.0",
|
"@svgr/webpack": "^5.5.0",
|
||||||
"@types/chart.js": "^2.9.31",
|
|
||||||
"@types/classnames": "^2.2.11",
|
"@types/classnames": "^2.2.11",
|
||||||
"@types/enzyme": "^3.10.8",
|
"@types/enzyme": "^3.10.10",
|
||||||
"@types/jest": "^26.0.20",
|
"@types/jest": "^27.0.2",
|
||||||
"@types/leaflet": "^1.5.23",
|
"@types/leaflet": "^1.5.23",
|
||||||
"@types/qs": "^6.9.5",
|
"@types/qs": "^6.9.5",
|
||||||
"@types/ramda": "^0.27.38",
|
"@types/ramda": "^0.27.38",
|
||||||
@@ -88,18 +89,18 @@
|
|||||||
"@types/react-router-dom": "^5.1.7",
|
"@types/react-router-dom": "^5.1.7",
|
||||||
"@types/react-tag-autocomplete": "^6.1.0",
|
"@types/react-tag-autocomplete": "^6.1.0",
|
||||||
"@types/uuid": "^8.3.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",
|
"adm-zip": "^0.4.16",
|
||||||
"autoprefixer": "^10.0.2",
|
"autoprefixer": "^10.0.2",
|
||||||
"babel-core": "7.0.0-bridge.0",
|
"babel-core": "7.0.0-bridge.0",
|
||||||
"babel-jest": "^26.6.3",
|
"babel-jest": "^27.3.1",
|
||||||
"babel-loader": "^8.2.1",
|
"babel-loader": "^8.2.1",
|
||||||
"babel-plugin-named-asset-import": "^0.3.7",
|
"babel-plugin-named-asset-import": "^0.3.7",
|
||||||
"babel-preset-react-app": "^10.0.0",
|
"babel-preset-react-app": "^10.0.0",
|
||||||
"babel-runtime": "^6.26.0",
|
"babel-runtime": "^6.26.0",
|
||||||
"bfj": "^7.0.2",
|
"bfj": "^7.0.2",
|
||||||
"case-sensitive-paths-webpack-plugin": "^2.3.0",
|
"case-sensitive-paths-webpack-plugin": "^2.3.0",
|
||||||
"chalk": "^4.1.0",
|
"chalk": "^4.1.2",
|
||||||
"css-loader": "^5.0.1",
|
"css-loader": "^5.0.1",
|
||||||
"dart-sass": "^1.25.0",
|
"dart-sass": "^1.25.0",
|
||||||
"dotenv": "^8.2.0",
|
"dotenv": "^8.2.0",
|
||||||
@@ -112,9 +113,9 @@
|
|||||||
"fs-extra": "^9.0.1",
|
"fs-extra": "^9.0.1",
|
||||||
"html-webpack-plugin": "^4.5.0",
|
"html-webpack-plugin": "^4.5.0",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^26.6.3",
|
"jest": "^27.3.1",
|
||||||
"jest-pnp-resolver": "^1.2.2",
|
"jest-pnp-resolver": "^1.2.2",
|
||||||
"jest-resolve": "^26.6.2",
|
"jest-resolve": "^27.3.1",
|
||||||
"mini-css-extract-plugin": "^1.3.1",
|
"mini-css-extract-plugin": "^1.3.1",
|
||||||
"object-assign": "^4.1.1",
|
"object-assign": "^4.1.1",
|
||||||
"optimize-css-assets-webpack-plugin": "^5.0.4",
|
"optimize-css-assets-webpack-plugin": "^5.0.4",
|
||||||
@@ -140,9 +141,9 @@
|
|||||||
"stylelint-scss": "^3.18.0",
|
"stylelint-scss": "^3.18.0",
|
||||||
"sw-precache-webpack-plugin": "^1.0.0",
|
"sw-precache-webpack-plugin": "^1.0.0",
|
||||||
"terser-webpack-plugin": "^4.2.3",
|
"terser-webpack-plugin": "^4.2.3",
|
||||||
"ts-jest": "^26.5.2",
|
"ts-jest": "^27.0.7",
|
||||||
"ts-mockery": "^1.2.0",
|
"ts-mockery": "^1.2.0",
|
||||||
"typescript": "^4.2.2",
|
"typescript": "^4.4.4",
|
||||||
"url-loader": "^4.1.1",
|
"url-loader": "^4.1.1",
|
||||||
"webpack": "^4.44.2",
|
"webpack": "^4.44.2",
|
||||||
"webpack-dev-server": "^3.11.0",
|
"webpack-dev-server": "^3.11.0",
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import qs from 'qs';
|
|
||||||
import { isEmpty, isNil, reject } from 'ramda';
|
import { isEmpty, isNil, reject } from 'ramda';
|
||||||
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
||||||
import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
|
|
||||||
import { ShortUrl, ShortUrlData } from '../../short-urls/data';
|
import { ShortUrl, ShortUrlData } from '../../short-urls/data';
|
||||||
import { OptionalString } from '../../utils/utils';
|
import { OptionalString } from '../../utils/utils';
|
||||||
import {
|
import {
|
||||||
@@ -16,7 +14,11 @@ import {
|
|||||||
ShlinkDomain,
|
ShlinkDomain,
|
||||||
ShlinkDomainsResponse,
|
ShlinkDomainsResponse,
|
||||||
ShlinkVisitsOverview,
|
ShlinkVisitsOverview,
|
||||||
|
ShlinkEditDomainRedirects,
|
||||||
|
ShlinkDomainRedirects,
|
||||||
|
ShlinkShortUrlsListParams,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import { stringifyQuery } from '../../utils/helpers/query';
|
||||||
|
|
||||||
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
||||||
const rejectNilProps = reject(isNil);
|
const rejectNilProps = reject(isNil);
|
||||||
@@ -32,7 +34,7 @@ export default class ShlinkApiClient {
|
|||||||
this.apiVersion = 2;
|
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)
|
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params)
|
||||||
.then(({ data }) => data.shortUrls);
|
.then(({ data }) => data.shortUrls);
|
||||||
|
|
||||||
@@ -108,6 +110,11 @@ export default class ShlinkApiClient {
|
|||||||
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
|
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
|
||||||
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data);
|
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>> => {
|
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
|
||||||
try {
|
try {
|
||||||
return await this.axios({
|
return await this.axios({
|
||||||
@@ -116,9 +123,9 @@ export default class ShlinkApiClient {
|
|||||||
headers: { 'X-Api-Key': this.apiKey },
|
headers: { 'X-Api-Key': this.apiKey },
|
||||||
params: rejectNilProps(query),
|
params: rejectNilProps(query),
|
||||||
data: body,
|
data: body,
|
||||||
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
|
paramsSerializer: stringifyQuery,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
const { response } = e;
|
const { response } = e;
|
||||||
|
|
||||||
// Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error
|
// 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 { Visit } from '../../visits/types';
|
||||||
import { OptionalString } from '../../utils/utils';
|
import { OptionalString } from '../../utils/utils';
|
||||||
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
|
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
|
||||||
|
import { OrderBy } from '../../short-urls/reducers/shortUrlsListParams';
|
||||||
|
|
||||||
export interface ShlinkShortUrlsResponse {
|
export interface ShlinkShortUrlsResponse {
|
||||||
data: ShortUrl[];
|
data: ShortUrl[];
|
||||||
@@ -25,12 +26,12 @@ interface ShlinkTagsStats {
|
|||||||
|
|
||||||
export interface ShlinkTags {
|
export interface ShlinkTags {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2
|
stats: ShlinkTagsStats[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkTagsResponse {
|
export interface ShlinkTagsResponse {
|
||||||
data: string[];
|
data: string[];
|
||||||
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2
|
stats: ShlinkTagsStats[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkPaginator {
|
export interface ShlinkPaginator {
|
||||||
@@ -65,21 +66,41 @@ export interface ShlinkShortUrlData extends ShortUrlMeta {
|
|||||||
tags?: string[];
|
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 {
|
export interface ShlinkDomain {
|
||||||
domain: string;
|
domain: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
|
redirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.8
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkDomainsResponse {
|
export interface ShlinkDomainsResponse {
|
||||||
data: ShlinkDomain[];
|
data: ShlinkDomain[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShlinkShortUrlsListParams {
|
||||||
|
page?: string;
|
||||||
|
itemsPerPage?: number;
|
||||||
|
tags?: string[];
|
||||||
|
searchTerm?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
orderBy?: OrderBy;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProblemDetailsError {
|
export interface ProblemDetailsError {
|
||||||
type: string;
|
type: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: number;
|
status: number;
|
||||||
|
|
||||||
[extraProps: string]: any;
|
[extraProps: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import './utils/base';
|
@import '../utils/base';
|
||||||
|
|
||||||
.app-container {
|
.app-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useEffect, FC } from 'react';
|
import { useEffect, FC } from 'react';
|
||||||
import { Route, Switch } from 'react-router-dom';
|
import { Route, RouteChildrenProps, Switch } from 'react-router-dom';
|
||||||
import NotFound from './common/NotFound';
|
import classNames from 'classnames';
|
||||||
import { ServersMap } from './servers/data';
|
import NotFound from '../common/NotFound';
|
||||||
import { Settings } from './settings/reducers/settings';
|
import { ServersMap } from '../servers/data';
|
||||||
import { changeThemeInMarkup } from './utils/theme';
|
import { Settings } from '../settings/reducers/settings';
|
||||||
import { AppUpdateBanner } from './common/AppUpdateBanner';
|
import { changeThemeInMarkup } from '../utils/theme';
|
||||||
import { forceUpdate } from './utils/helpers/sw';
|
import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
||||||
|
import { forceUpdate } from '../utils/helpers/sw';
|
||||||
import './App.scss';
|
import './App.scss';
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps extends RouteChildrenProps {
|
||||||
fetchServers: () => void;
|
fetchServers: () => void;
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
@@ -23,8 +24,11 @@ const App = (
|
|||||||
CreateServer: FC,
|
CreateServer: FC,
|
||||||
EditServer: FC,
|
EditServer: FC,
|
||||||
Settings: FC,
|
Settings: FC,
|
||||||
|
ManageServers: FC,
|
||||||
ShlinkVersionsContainer: FC,
|
ShlinkVersionsContainer: FC,
|
||||||
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
|
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate, location }: AppProps) => {
|
||||||
|
const isHome = location.pathname === '/';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// On first load, try to fetch the remote servers if the list is empty
|
// On first load, try to fetch the remote servers if the list is empty
|
||||||
if (Object.keys(servers).length === 0) {
|
if (Object.keys(servers).length === 0) {
|
||||||
@@ -39,10 +43,11 @@ const App = (
|
|||||||
<MainHeader />
|
<MainHeader />
|
||||||
|
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<div className="shlink-wrapper">
|
<div className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path="/" component={Home} />
|
<Route exact path="/" component={Home} />
|
||||||
<Route exact path="/settings" component={Settings} />
|
<Route exact path="/settings" component={Settings} />
|
||||||
|
<Route exact path="/manage-servers" component={ManageServers} />
|
||||||
<Route exact path="/server/create" component={CreateServer} />
|
<Route exact path="/server/create" component={CreateServer} />
|
||||||
<Route exact path="/server/:serverId/edit" component={EditServer} />
|
<Route exact path="/server/:serverId/edit" component={EditServer} />
|
||||||
<Route path="/server/:serverId" component={MenuLayout} />
|
<Route path="/server/:serverId" component={MenuLayout} />
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import Bottle from 'bottlejs';
|
import Bottle, { Decorator } from 'bottlejs';
|
||||||
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
|
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
|
||||||
import App from '../../App';
|
import App from '../App';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory(
|
bottle.serviceFactory(
|
||||||
'App',
|
'App',
|
||||||
@@ -14,9 +14,11 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||||||
'CreateServer',
|
'CreateServer',
|
||||||
'EditServer',
|
'EditServer',
|
||||||
'Settings',
|
'Settings',
|
||||||
|
'ManageServers',
|
||||||
'ShlinkVersionsContainer',
|
'ShlinkVersionsContainer',
|
||||||
);
|
);
|
||||||
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ]));
|
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ]));
|
||||||
|
bottle.decorator('App', withRouter);
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);
|
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
faTags as tagsIcon,
|
faTags as tagsIcon,
|
||||||
faPen as editIcon,
|
faPen as editIcon,
|
||||||
faHome as overviewIcon,
|
faHome as overviewIcon,
|
||||||
|
faGlobe as domainsIcon,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
@@ -11,11 +12,12 @@ import { NavLink, NavLinkProps } from 'react-router-dom';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Location } from 'history';
|
import { Location } from 'history';
|
||||||
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
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';
|
import './AsideMenu.scss';
|
||||||
|
|
||||||
export interface AsideMenuProps {
|
export interface AsideMenuProps {
|
||||||
selectedServer: ServerWithId;
|
selectedServer: SelectedServer;
|
||||||
className?: string;
|
className?: string;
|
||||||
showOnMobile?: boolean;
|
showOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
@@ -38,7 +40,9 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
|
|||||||
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||||
{ selectedServer, showOnMobile = false }: AsideMenuProps,
|
{ 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', {
|
const asideClass = classNames('aside-menu', {
|
||||||
'aside-menu--hidden': !showOnMobile,
|
'aside-menu--hidden': !showOnMobile,
|
||||||
});
|
});
|
||||||
@@ -49,30 +53,38 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
|||||||
<aside className={asideClass}>
|
<aside className={asideClass}>
|
||||||
<nav className="nav flex-column aside-menu__nav">
|
<nav className="nav flex-column aside-menu__nav">
|
||||||
<AsideMenuItem to={buildPath('/overview')}>
|
<AsideMenuItem to={buildPath('/overview')}>
|
||||||
<FontAwesomeIcon icon={overviewIcon} />
|
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
|
||||||
<span className="aside-menu__item-text">Overview</span>
|
<span className="aside-menu__item-text">Overview</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
<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>
|
<span className="aside-menu__item-text">List short URLs</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
<AsideMenuItem to={buildPath('/create-short-url')}>
|
<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>
|
<span className="aside-menu__item-text">Create short URL</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||||
<FontAwesomeIcon icon={tagsIcon} />
|
<FontAwesomeIcon fixedWidth icon={tagsIcon} />
|
||||||
<span className="aside-menu__item-text">Manage tags</span>
|
<span className="aside-menu__item-text">Manage tags</span>
|
||||||
</AsideMenuItem>
|
</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">
|
<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>
|
<span className="aside-menu__item-text">Edit this server</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
<DeleteServerButton
|
{hasId && (
|
||||||
className="aside-menu__item aside-menu__item--danger"
|
<DeleteServerButton
|
||||||
textClassName="aside-menu__item-text"
|
className="aside-menu__item aside-menu__item--danger"
|
||||||
server={selectedServer}
|
textClassName="aside-menu__item-text"
|
||||||
/>
|
server={selectedServer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
.home {
|
.home {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
@media (min-width: $mdMin) {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import { isEmpty, values } from 'ramda';
|
import { isEmpty, values } from 'ramda';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, RouteChildrenProps } from 'react-router-dom';
|
||||||
import { Card, Row } from 'reactstrap';
|
import { Card, Row } from 'reactstrap';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
@@ -9,14 +10,21 @@ import { ServersMap } from '../servers/data';
|
|||||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
import './Home.scss';
|
import './Home.scss';
|
||||||
|
|
||||||
export interface HomeProps {
|
export interface HomeProps extends RouteChildrenProps {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Home = ({ servers }: HomeProps) => {
|
const Home = ({ servers, history }: HomeProps) => {
|
||||||
const serversList = values(servers);
|
const serversList = values(servers);
|
||||||
const hasServers = !isEmpty(serversList);
|
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 (
|
return (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
<Card className="home__main-card">
|
<Card className="home__main-card">
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
|
|||||||
<Collapse navbar isOpen={isOpen}>
|
<Collapse navbar isOpen={isOpen}>
|
||||||
<Nav navbar className="ml-auto">
|
<Nav navbar className="ml-auto">
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink tag={Link} to={'/settings'} active={pathname === settingsPath}>
|
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
||||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||||
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
|
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 { isReachableServer } from '../servers/data';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
import { AsideMenuProps } from './AsideMenu';
|
import { AsideMenuProps } from './AsideMenu';
|
||||||
@@ -13,7 +13,7 @@ import './MenuLayout.scss';
|
|||||||
|
|
||||||
const MenuLayout = (
|
const MenuLayout = (
|
||||||
TagsList: FC,
|
TagsList: FC,
|
||||||
ShortUrls: FC,
|
ShortUrlsList: FC,
|
||||||
AsideMenu: FC<AsideMenuProps>,
|
AsideMenu: FC<AsideMenuProps>,
|
||||||
CreateShortUrl: FC,
|
CreateShortUrl: FC,
|
||||||
ShortUrlVisits: FC,
|
ShortUrlVisits: FC,
|
||||||
@@ -22,6 +22,7 @@ const MenuLayout = (
|
|||||||
ServerError: FC,
|
ServerError: FC,
|
||||||
Overview: FC,
|
Overview: FC,
|
||||||
EditShortUrl: FC,
|
EditShortUrl: FC,
|
||||||
|
ManageDomains: FC,
|
||||||
) => withSelectedServer(({ location, selectedServer }) => {
|
) => withSelectedServer(({ location, selectedServer }) => {
|
||||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
||||||
|
|
||||||
@@ -31,8 +32,8 @@ const MenuLayout = (
|
|||||||
return <ServerError />;
|
return <ServerError />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const addTagsVisitsRoute = supportsTagVisits(selectedServer);
|
|
||||||
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
|
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
|
||||||
|
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
|
||||||
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
||||||
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
||||||
|
|
||||||
@@ -48,13 +49,14 @@ const MenuLayout = (
|
|||||||
<Switch>
|
<Switch>
|
||||||
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
|
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
|
||||||
<Route exact path="/server/:serverId/overview" component={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 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/visits" component={ShortUrlVisits} />
|
||||||
<Route path="/server/:serverId/short-code/:shortCode/edit" component={EditShortUrl} />
|
<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} />}
|
{addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />}
|
||||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||||
|
{addManageDomainsRoute && <Route exact path="/server/:serverId/manage-domains" component={ManageDomains} />}
|
||||||
<Route
|
<Route
|
||||||
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||||
/>
|
/>
|
||||||
|
|||||||
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 ShlinkVersionsContainer from '../ShlinkVersionsContainer';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||||
|
import { ImageDownloader } from './ImageDownloader';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||||
|
// Services
|
||||||
bottle.constant('window', (global as any).window);
|
bottle.constant('window', (global as any).window);
|
||||||
bottle.constant('console', global.console);
|
bottle.constant('console', global.console);
|
||||||
bottle.constant('axios', axios);
|
bottle.constant('axios', axios);
|
||||||
|
|
||||||
|
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
|
||||||
|
|
||||||
|
// Components
|
||||||
bottle.serviceFactory('ScrollToTop', ScrollToTop);
|
bottle.serviceFactory('ScrollToTop', ScrollToTop);
|
||||||
bottle.decorator('ScrollToTop', withRouter);
|
bottle.decorator('ScrollToTop', withRouter);
|
||||||
|
|
||||||
@@ -23,13 +28,14 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
|||||||
|
|
||||||
bottle.serviceFactory('Home', () => Home);
|
bottle.serviceFactory('Home', () => Home);
|
||||||
bottle.decorator('Home', withoutSelectedServer);
|
bottle.decorator('Home', withoutSelectedServer);
|
||||||
|
bottle.decorator('Home', withRouter);
|
||||||
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
|
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory(
|
bottle.serviceFactory(
|
||||||
'MenuLayout',
|
'MenuLayout',
|
||||||
MenuLayout,
|
MenuLayout,
|
||||||
'TagsList',
|
'TagsList',
|
||||||
'ShortUrls',
|
'ShortUrlsList',
|
||||||
'AsideMenu',
|
'AsideMenu',
|
||||||
'CreateShortUrl',
|
'CreateShortUrl',
|
||||||
'ShortUrlVisits',
|
'ShortUrlVisits',
|
||||||
@@ -38,8 +44,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
|||||||
'ServerError',
|
'ServerError',
|
||||||
'Overview',
|
'Overview',
|
||||||
'EditShortUrl',
|
'EditShortUrl',
|
||||||
|
'ManageDomains',
|
||||||
);
|
);
|
||||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer' ]));
|
||||||
bottle.decorator('MenuLayout', withRouter);
|
bottle.decorator('MenuLayout', withRouter);
|
||||||
|
|
||||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
|
|||||||
actionServiceNames.reduce(mapActionService, {}),
|
actionServiceNames.reduce(mapActionService, {}),
|
||||||
);
|
);
|
||||||
|
|
||||||
provideAppServices(bottle, connect);
|
provideAppServices(bottle, connect, withRouter);
|
||||||
provideCommonServices(bottle, connect, withRouter);
|
provideCommonServices(bottle, connect, withRouter);
|
||||||
provideApiServices(bottle);
|
provideApiServices(bottle);
|
||||||
provideShortUrlsServices(bottle, connect);
|
provideShortUrlsServices(bottle, connect, withRouter);
|
||||||
provideServersServices(bottle, connect, withRouter);
|
provideServersServices(bottle, connect, withRouter);
|
||||||
provideTagsServices(bottle, connect);
|
provideTagsServices(bottle, connect);
|
||||||
provideVisitsServices(bottle, connect);
|
provideVisitsServices(bottle, connect);
|
||||||
|
|||||||
72
src/domains/DomainRow.tsx
Normal file
72
src/domains/DomainRow.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
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 fixedWidth 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;
|
||||||
|
|
||||||
|
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={isDefault ? 'defaultDomainBtn' : undefined}>
|
||||||
|
<Button outline size="sm" disabled={isDefault} onClick={isDefault ? undefined : toggle}>
|
||||||
|
<FontAwesomeIcon fixedWidth icon={isDefault ? forbiddenIcon : editIcon} />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
{isDefault && (
|
||||||
|
<UncontrolledTooltip target="defaultDomainBtn" 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 { Action, Dispatch } from 'redux';
|
||||||
import { ShlinkDomain } from '../../api/types';
|
import { ProblemDetailsError, ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { buildReducer } from '../../utils/helpers/redux';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { GetState } from '../../container/types';
|
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 */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
|
export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
|
||||||
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
|
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
|
||||||
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
|
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
|
||||||
|
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
export interface DomainsList {
|
export interface DomainsList {
|
||||||
domains: ShlinkDomain[];
|
domains: ShlinkDomain[];
|
||||||
|
filteredDomains: ShlinkDomain[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
|
errorData?: ProblemDetailsError;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListDomainsAction extends Action<string> {
|
export interface ListDomainsAction extends Action<string> {
|
||||||
domains: ShlinkDomain[];
|
domains: ShlinkDomain[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FilterDomainsAction extends Action<string> {
|
||||||
|
searchTerm: string;
|
||||||
|
}
|
||||||
|
|
||||||
const initialState: DomainsList = {
|
const initialState: DomainsList = {
|
||||||
domains: [],
|
domains: [],
|
||||||
|
filteredDomains: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: 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_START]: () => ({ ...initialState, loading: true }),
|
||||||
[LIST_DOMAINS_ERROR]: () => ({ ...initialState, error: true }),
|
[LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }),
|
||||||
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains }),
|
[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);
|
}, initialState);
|
||||||
|
|
||||||
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
||||||
@@ -43,7 +71,9 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
|
|||||||
const domains = await listDomains();
|
const domains = await listDomains();
|
||||||
|
|
||||||
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
|
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch({ type: LIST_DOMAINS_ERROR });
|
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm });
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { listDomains } from '../reducers/domainsList';
|
import { filterDomains, listDomains } from '../reducers/domainsList';
|
||||||
import { DomainSelector } from '../DomainSelector';
|
import { DomainSelector } from '../DomainSelector';
|
||||||
|
import { ManageDomains } from '../ManageDomains';
|
||||||
|
import { editDomainRedirects } from '../reducers/domainRedirects';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('DomainSelector', () => DomainSelector);
|
bottle.serviceFactory('DomainSelector', () => DomainSelector);
|
||||||
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ]));
|
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
||||||
|
bottle.decorator('ManageDomains', connect(
|
||||||
|
[ 'domainsList' ],
|
||||||
|
[ 'listDomains', 'filterDomains', 'editDomainRedirects' ],
|
||||||
|
));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('filterDomains', () => filterDomains);
|
||||||
|
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default provideServices;
|
export default provideServices;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
||||||
@import './common/react-tag-autocomplete.scss';
|
@import './common/react-tag-autocomplete.scss';
|
||||||
@import './theme/theme';
|
@import './theme/theme';
|
||||||
|
@import './utils/table/ResponsiveTable';
|
||||||
|
@import './utils/StickyCardPaginator';
|
||||||
|
|
||||||
* {
|
* {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
@@ -113,6 +115,16 @@ hr {
|
|||||||
color: var(--text-color) !important;
|
color: var(--text-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-item--danger.dropdown-item--danger {
|
||||||
|
color: $dangerColor;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
color: $dangerColor !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.badge-main {
|
.badge-main {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background-color: var(--brand-color);
|
background-color: var(--brand-color);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export class Topics {
|
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;
|
text-align: right;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-server__csv-select {
|
|
||||||
position: absolute;
|
|
||||||
left: -9999px;
|
|
||||||
top: -9999px;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,30 +1,35 @@
|
|||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { RouterProps } from 'react-router';
|
import { RouterProps } from 'react-router';
|
||||||
|
import { Button } from 'reactstrap';
|
||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
import NoMenuLayout from '../common/NoMenuLayout';
|
import NoMenuLayout from '../common/NoMenuLayout';
|
||||||
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||||
import { ServerData, ServerWithId } from './data';
|
import { ServerData, ServersMap, ServerWithId } from './data';
|
||||||
import './CreateServer.scss';
|
import './CreateServer.scss';
|
||||||
|
|
||||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||||
|
|
||||||
interface CreateServerProps extends RouterProps {
|
interface CreateServerProps extends RouterProps {
|
||||||
createServer: (server: ServerWithId) => void;
|
createServer: (server: ServerWithId) => void;
|
||||||
|
servers: ServersMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
||||||
<Result type={type}>
|
<div className="mt-3">
|
||||||
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
<Result type={type}>
|
||||||
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
||||||
</Result>
|
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
||||||
|
</Result>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
|
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 [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||||
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||||
const handleSubmit = (serverData: ServerData) => {
|
const handleSubmit = (serverData: ServerData) => {
|
||||||
@@ -37,16 +42,14 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
|
|||||||
return (
|
return (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}>
|
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}>
|
||||||
<ImportServersBtn onImport={setServersImported} onImportError={setErrorImporting} />
|
{!hasServers &&
|
||||||
<button className="btn btn-outline-primary">Create server</button>
|
<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>
|
</ServerForm>
|
||||||
|
|
||||||
{(serversImported || errorImporting) && (
|
{serversImported && <ImportResult type="success" />}
|
||||||
<div className="mt-3">
|
{errorImporting && <ImportResult type="error" />}
|
||||||
{serversImported && <ImportResult type="success" />}
|
|
||||||
{errorImporting && <ImportResult type="error" />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<D
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className={className} onClick={showModal}>
|
<span className={className} onClick={showModal}>
|
||||||
{!children && <FontAwesomeIcon icon={deleteIcon} />}
|
{!children && <FontAwesomeIcon fixedWidth icon={deleteIcon} />}
|
||||||
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import { RouterProps } from 'react-router';
|
import { RouterProps } from 'react-router';
|
||||||
import { ServerWithId } from './data';
|
import { ServerWithId } from './data';
|
||||||
@@ -6,17 +7,20 @@ export interface DeleteServerModalProps {
|
|||||||
server: ServerWithId;
|
server: ServerWithId;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
redirectHome?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps {
|
interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps {
|
||||||
deleteServer: (server: ServerWithId) => void;
|
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 = () => {
|
const closeModal = () => {
|
||||||
deleteServer(server);
|
deleteServer(server);
|
||||||
toggle();
|
toggle();
|
||||||
history.push('/');
|
redirectHome && history.push('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface EditServerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
|
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
|
||||||
{ editServer, selectedServer, history: { push, goBack } },
|
{ editServer, selectedServer, history: { goBack } },
|
||||||
) => {
|
) => {
|
||||||
if (!isServerWithId(selectedServer)) {
|
if (!isServerWithId(selectedServer)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -18,7 +18,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
|
|||||||
|
|
||||||
const handleSubmit = (serverData: ServerData) => {
|
const handleSubmit = (serverData: ServerData) => {
|
||||||
editServer(selectedServer.id, serverData);
|
editServer(selectedServer.id, serverData);
|
||||||
push(`/server/${selectedServer.id}`);
|
goBack();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 { FC, useEffect } from 'react';
|
||||||
import { Card, CardBody, CardHeader, CardText, CardTitle, Row } from 'reactstrap';
|
import { Card, CardBody, CardHeader, CardText, CardTitle, Row } from 'reactstrap';
|
||||||
import { Link, useHistory } from 'react-router-dom';
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
|
|
||||||
import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
||||||
import { prettify } from '../utils/helpers/numbers';
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
import { TagsList } from '../tags/reducers/tagsList';
|
import { TagsList } from '../tags/reducers/tagsList';
|
||||||
@@ -11,12 +10,13 @@ import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
|||||||
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||||
import { Versions } from '../utils/helpers/version';
|
import { Versions } from '../utils/helpers/version';
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
import { isServerWithId, SelectedServer } from './data';
|
import { ShlinkShortUrlsListParams } from '../api/types';
|
||||||
|
import { getServerId, SelectedServer } from './data';
|
||||||
import './Overview.scss';
|
import './Overview.scss';
|
||||||
|
|
||||||
interface OverviewConnectProps {
|
interface OverviewConnectProps {
|
||||||
shortUrlsList: ShortUrlsListState;
|
shortUrlsList: ShortUrlsListState;
|
||||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
||||||
listTags: Function;
|
listTags: Function;
|
||||||
tagsList: TagsList;
|
tagsList: TagsList;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
@@ -40,7 +40,7 @@ export const Overview = (
|
|||||||
const { loading, shortUrls } = shortUrlsList;
|
const { loading, shortUrls } = shortUrlsList;
|
||||||
const { loading: loadingTags } = tagsList;
|
const { loading: loadingTags } = tagsList;
|
||||||
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
|
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
|
||||||
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
const serverId = getServerId(selectedServer);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,14 +55,7 @@ export const Overview = (
|
|||||||
<div className="col-md-6 col-xl-3">
|
<div className="col-md-6 col-xl-3">
|
||||||
<Card className="overview__card mb-3" body>
|
<Card className="overview__card mb-3" body>
|
||||||
<CardTitle tag="h5" className="overview__card-title">Visits</CardTitle>
|
<CardTitle tag="h5" className="overview__card-title">Visits</CardTitle>
|
||||||
<CardText tag="h2">
|
<CardText tag="h2">{loadingVisits ? 'Loading...' : prettify(visitsCount)}</CardText>
|
||||||
<ForServerVersion minVersion="2.2.0">
|
|
||||||
{loadingVisits ? 'Loading...' : prettify(visitsCount)}
|
|
||||||
</ForServerVersion>
|
|
||||||
<ForServerVersion maxVersion="2.1.*">
|
|
||||||
<small className="text-muted"><i>Shlink 2.2 is needed</i></small>
|
|
||||||
</ForServerVersion>
|
|
||||||
</CardText>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6 col-xl-3">
|
<div className="col-md-6 col-xl-3">
|
||||||
@@ -114,10 +107,10 @@ export const Overview = (
|
|||||||
shortUrlsList={shortUrlsList}
|
shortUrlsList={shortUrlsList}
|
||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
className="mb-0"
|
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>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, () => [ Topics.visits(), Topics.orphanVisits() ]);
|
}, () => [ Topics.visits, Topics.orphanVisits ]);
|
||||||
|
|||||||
@@ -1,45 +1,37 @@
|
|||||||
import { isEmpty, values } from 'ramda';
|
import { isEmpty, values } from 'ramda';
|
||||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { faPlus as plusIcon, faFileDownload as exportIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import ServersExporter from './services/ServersExporter';
|
import { getServerId, SelectedServer, ServersMap } from './data';
|
||||||
import { isServerWithId, SelectedServer, ServersMap } from './data';
|
|
||||||
|
|
||||||
export interface ServersDropdownProps {
|
export interface ServersDropdownProps {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, selectedServer }: ServersDropdownProps) => {
|
const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||||
const serversList = values(servers);
|
const serversList = values(servers);
|
||||||
const createServerItem = (
|
|
||||||
<DropdownItem tag={Link} to="/server/create">
|
|
||||||
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add a server</span>
|
|
||||||
</DropdownItem>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderServers = () => {
|
const renderServers = () => {
|
||||||
if (isEmpty(serversList)) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{serversList.map(({ name, id }) => (
|
{serversList.map(({ name, id }) => (
|
||||||
<DropdownItem
|
<DropdownItem key={id} tag={Link} to={`/server/${id}`} active={getServerId(selectedServer) === id}>
|
||||||
key={id}
|
|
||||||
tag={Link}
|
|
||||||
to={`/server/${id}`}
|
|
||||||
active={isServerWithId(selectedServer) && selectedServer.id === id}
|
|
||||||
>
|
|
||||||
{name}
|
{name}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
))}
|
))}
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
{createServerItem}
|
<DropdownItem tag={Link} to="/manage-servers">
|
||||||
<DropdownItem className="servers-dropdown__export-item" onClick={async () => serversExporter.exportServers()}>
|
<FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Manage servers</span>
|
||||||
<FontAwesomeIcon icon={exportIcon} /> <span className="ml-1">Export servers</span>
|
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { omit } from 'ramda';
|
||||||
import { SemVer } from '../../utils/helpers/version';
|
import { SemVer } from '../../utils/helpers/version';
|
||||||
|
|
||||||
export interface ServerData {
|
export interface ServerData {
|
||||||
@@ -8,6 +9,7 @@ export interface ServerData {
|
|||||||
|
|
||||||
export interface ServerWithId extends ServerData {
|
export interface ServerWithId extends ServerData {
|
||||||
id: string;
|
id: string;
|
||||||
|
autoConnect?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReachableServer extends ServerWithId {
|
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 =>
|
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
|
||||||
!!server?.hasOwnProperty('serverNotFound');
|
!!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 { useRef, RefObject, ChangeEvent, MutableRefObject, FC } from 'react';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
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 ServersImporter from '../services/ServersImporter';
|
||||||
import { ServerData } from '../data';
|
import { ServerData } from '../data';
|
||||||
|
import './ImportServersBtn.scss';
|
||||||
|
|
||||||
type Ref<T> = RefObject<T> | MutableRefObject<T>;
|
type Ref<T> = RefObject<T> | MutableRefObject<T>;
|
||||||
|
|
||||||
export interface ImportServersBtnProps {
|
export interface ImportServersBtnProps {
|
||||||
onImport?: () => void;
|
onImport?: () => void;
|
||||||
onImportError?: (error: Error) => void;
|
onImportError?: (error: Error) => void;
|
||||||
|
tooltipPlacement?: 'top' | 'bottom';
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
||||||
@@ -15,17 +21,19 @@ interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
|||||||
fileRef: Ref<HTMLInputElement>;
|
fileRef: Ref<HTMLInputElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
|
const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<ImportServersBtnConnectProps> => ({
|
||||||
createServers,
|
createServers,
|
||||||
fileRef,
|
fileRef,
|
||||||
|
children,
|
||||||
onImport = () => {},
|
onImport = () => {},
|
||||||
onImportError = () => {},
|
onImportError = () => {},
|
||||||
}: ImportServersBtnConnectProps) => {
|
tooltipPlacement = 'bottom',
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
const ref = fileRef ?? useRef<HTMLInputElement>();
|
const ref = fileRef ?? useRef<HTMLInputElement>();
|
||||||
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||||
importServersFromFile(target.files?.[0])
|
importServersFromFile(target.files?.[0])
|
||||||
.then(createServers)
|
.then(pipe(createServers, onImport))
|
||||||
.then(onImport)
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Reset input after processing file
|
// Reset input after processing file
|
||||||
(target as { value: string | null }).value = null;
|
(target as { value: string | null }).value = null;
|
||||||
@@ -34,19 +42,14 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}>
|
||||||
type="button"
|
<FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? 'Import from file'}
|
||||||
className="btn btn-outline-secondary mr-2"
|
</Button>
|
||||||
id="importBtn"
|
<UncontrolledTooltip placement={tooltipPlacement} target="importBtn">
|
||||||
onClick={() => ref.current?.click()}
|
|
||||||
>
|
|
||||||
Import from file
|
|
||||||
</button>
|
|
||||||
<UncontrolledTooltip placement="top" target="importBtn">
|
|
||||||
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
|
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
|
||||||
</UncontrolledTooltip>
|
</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 { FC, ReactNode, useEffect, useState } from 'react';
|
||||||
import { FormGroupContainer } from '../../utils/FormGroupContainer';
|
import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer';
|
||||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||||
import { ServerData } from '../data';
|
import { ServerData } from '../data';
|
||||||
import { SimpleCard } from '../../utils/SimpleCard';
|
import { SimpleCard } from '../../utils/SimpleCard';
|
||||||
@@ -11,6 +11,9 @@ interface ServerFormProps {
|
|||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FormGroup: FC<FormGroupContainerProps> = (props) =>
|
||||||
|
<FormGroupContainer {...props} labelClassName="create-server__label" />;
|
||||||
|
|
||||||
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
|
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
|
||||||
const [ name, setName ] = useState('');
|
const [ name, setName ] = useState('');
|
||||||
const [ url, setUrl ] = useState('');
|
const [ url, setUrl ] = useState('');
|
||||||
@@ -26,9 +29,9 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
|
|||||||
return (
|
return (
|
||||||
<form className="server-form" onSubmit={handleSubmit}>
|
<form className="server-form" onSubmit={handleSubmit}>
|
||||||
<SimpleCard className="mb-3" title={title}>
|
<SimpleCard className="mb-3" title={title}>
|
||||||
<FormGroupContainer value={name} onChange={setName}>Name</FormGroupContainer>
|
<FormGroup value={name} onChange={setName}>Name</FormGroup>
|
||||||
<FormGroupContainer type="url" value={url} onChange={setUrl}>URL</FormGroupContainer>
|
<FormGroup type="url" value={url} onChange={setUrl}>URL</FormGroup>
|
||||||
<FormGroupContainer value={apiKey} onChange={setApiKey}>API key</FormGroupContainer>
|
<FormGroup value={apiKey} onChange={setApiKey}>API key</FormGroup>
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
|
|
||||||
<div className="text-right">{children}</div>
|
<div className="text-right">{children}</div>
|
||||||
|
|||||||
@@ -2,24 +2,17 @@ import { pipe, prop } from 'ramda';
|
|||||||
import { AxiosInstance } from 'axios';
|
import { AxiosInstance } from 'axios';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import { homepage } from '../../../package.json';
|
import { homepage } from '../../../package.json';
|
||||||
import { ServerData } from '../data';
|
import { hasServerData, ServerData } from '../data';
|
||||||
import { createServers } from './servers';
|
import { createServers } from './servers';
|
||||||
|
|
||||||
const responseToServersList = pipe(
|
const responseToServersList = pipe(
|
||||||
prop<any, any>('data'),
|
prop<any, any>('data'),
|
||||||
(data: any): ServerData[] => {
|
(data: any): ServerData[] => Array.isArray(data) ? data.filter(hasServerData) : [],
|
||||||
if (!Array.isArray(data)) {
|
|
||||||
throw new Error('Value is not an array');
|
|
||||||
}
|
|
||||||
|
|
||||||
return data as ServerData[];
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {
|
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {
|
||||||
const remoteList = await get(`${homepage}/servers.json`)
|
const resp = await get(`${homepage}/servers.json`);
|
||||||
.then(responseToServersList)
|
const remoteList = responseToServersList(resp);
|
||||||
.catch(() => []);
|
|
||||||
|
|
||||||
dispatch(createServers(remoteList));
|
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 { v4 as uuid } from 'uuid';
|
||||||
import { Action } from 'redux';
|
import { Action } from 'redux';
|
||||||
import { ServerData, ServersMap, ServerWithId } from '../data';
|
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 EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
|
||||||
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
||||||
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
||||||
|
export const SET_AUTO_CONNECT = 'shlink/servers/SET_AUTO_CONNECT';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
export interface CreateServersAction extends Action<string> {
|
export interface CreateServersAction extends Action<string> {
|
||||||
newServers: ServersMap;
|
newServers: ServersMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DeleteServerAction extends Action<string> {
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetAutoConnectAction extends Action<string> {
|
||||||
|
serverId: string;
|
||||||
|
autoConnect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const initialState: ServersMap = {};
|
const initialState: ServersMap = {};
|
||||||
|
|
||||||
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
||||||
@@ -24,12 +34,28 @@ const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
|||||||
return assoc('id', uuid(), server);
|
return assoc('id', uuid(), server);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ServersMap, CreateServersAction>({
|
export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction & SetAutoConnectAction>({
|
||||||
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
|
[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]
|
[EDIT_SERVER]: (state, { serverId, serverData }: any) => !state[serverId]
|
||||||
? state
|
? state
|
||||||
: assoc(serverId, { ...state[serverId], ...serverData }, 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);
|
}, initialState);
|
||||||
|
|
||||||
const serversListToMap = reduce<ServerWithId, ServersMap>((acc, server) => assoc(server.id, server, acc), {});
|
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,
|
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 { CsvJson } from 'csvjson';
|
||||||
import LocalStorage from '../../utils/services/LocalStorage';
|
import LocalStorage from '../../utils/services/LocalStorage';
|
||||||
import { ServersMap } from '../data';
|
import { ServersMap, serverWithIdToServerData } from '../data';
|
||||||
import { saveCsv } from '../../utils/helpers/csv';
|
import { saveCsv } from '../../utils/helpers/files';
|
||||||
|
|
||||||
const SERVERS_FILENAME = 'shlink-servers.csv';
|
const SERVERS_FILENAME = 'shlink-servers.csv';
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export default class ServersExporter {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public readonly exportServers = async () => {
|
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 {
|
try {
|
||||||
const csv = this.csvjson.toCSV(servers, { headers: 'key' });
|
const csv = this.csvjson.toCSV(servers, { headers: 'key' });
|
||||||
|
|||||||
@@ -7,26 +7,44 @@ import DeleteServerButton from '../DeleteServerButton';
|
|||||||
import { EditServer } from '../EditServer';
|
import { EditServer } from '../EditServer';
|
||||||
import ImportServersBtn from '../helpers/ImportServersBtn';
|
import ImportServersBtn from '../helpers/ImportServersBtn';
|
||||||
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
|
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 { fetchServers } from '../reducers/remoteServers';
|
||||||
import ForServerVersion from '../helpers/ForServerVersion';
|
import ForServerVersion from '../helpers/ForServerVersion';
|
||||||
import { ServerError } from '../helpers/ServerError';
|
import { ServerError } from '../helpers/ServerError';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
|
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
|
||||||
import { Overview } from '../Overview';
|
import { Overview } from '../Overview';
|
||||||
|
import { ManageServers } from '../ManageServers';
|
||||||
|
import { ManageServersRow } from '../ManageServersRow';
|
||||||
|
import { ManageServersRowDropdown } from '../ManageServersRowDropdown';
|
||||||
import ServersImporter from './ServersImporter';
|
import ServersImporter from './ServersImporter';
|
||||||
import ServersExporter from './ServersExporter';
|
import ServersExporter from './ServersExporter';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||||
// Components
|
// 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.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
|
||||||
bottle.decorator('CreateServer', withoutSelectedServer);
|
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.serviceFactory('EditServer', EditServer, 'ServerError');
|
||||||
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
|
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
|
bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
|
||||||
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
|
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
||||||
@@ -62,6 +80,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
|||||||
bottle.serviceFactory('createServers', () => createServers);
|
bottle.serviceFactory('createServers', () => createServers);
|
||||||
bottle.serviceFactory('deleteServer', () => deleteServer);
|
bottle.serviceFactory('deleteServer', () => deleteServer);
|
||||||
bottle.serviceFactory('editServer', () => editServer);
|
bottle.serviceFactory('editServer', () => editServer);
|
||||||
|
bottle.serviceFactory('setAutoConnect', () => setAutoConnect);
|
||||||
bottle.serviceFactory('fetchServers', fetchServers, 'axios');
|
bottle.serviceFactory('fetchServers', fetchServers, 'axios');
|
||||||
|
|
||||||
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const RealTimeUpdates = (
|
|||||||
<SimpleCard title="Real-time updates" className="h-100">
|
<SimpleCard title="Real-time updates" className="h-100">
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
|
<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">
|
<small className="form-text text-muted">
|
||||||
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
|
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
|
||||||
</small>
|
</small>
|
||||||
@@ -34,7 +34,7 @@ const RealTimeUpdates = (
|
|||||||
placeholder="Immediate"
|
placeholder="Immediate"
|
||||||
disabled={!realTimeUpdates.enabled}
|
disabled={!realTimeUpdates.enabled}
|
||||||
value={intervalValue(realTimeUpdates.interval)}
|
value={intervalValue(realTimeUpdates.interval)}
|
||||||
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
|
onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))}
|
||||||
/>
|
/>
|
||||||
{realTimeUpdates.enabled && (
|
{realTimeUpdates.enabled && (
|
||||||
<small className="form-text text-muted">
|
<small className="form-text text-muted">
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC,
|
|||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<SettingsSections
|
<SettingsSections
|
||||||
items={[
|
items={[
|
||||||
[ <UserInterface />, <ShortUrlCreation /> ], // eslint-disable-line react/jsx-key
|
[ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key
|
||||||
[ <Visits />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
|
[ <ShortUrlCreation />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
|
|||||||
@@ -1,29 +1,74 @@
|
|||||||
import { FC } from 'react';
|
import { FC, ReactNode } from 'react';
|
||||||
import { FormGroup } from 'reactstrap';
|
import { DropdownItem, FormGroup } from 'reactstrap';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
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 {
|
interface ShortUrlCreationProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void;
|
setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShortUrlCreation: FC<ShortUrlCreationProps> = (
|
const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
|
||||||
{ settings: { shortUrlCreation }, setShortUrlCreationSettings },
|
tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input';
|
||||||
) => (
|
const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode =>
|
||||||
<SimpleCard title="Short URLs creation" className="h-100">
|
tagFilteringMode === 'includes'
|
||||||
<FormGroup className="mb-0">
|
? <>The list of suggested tags will contain those <b>including</b> provided input.</>
|
||||||
<ToggleSwitch
|
: <>The list of suggested tags will contain those <b>starting with</b> provided input.</>;
|
||||||
checked={shortUrlCreation?.validateUrls ?? false}
|
|
||||||
onChange={(validateUrls) => setShortUrlCreationSettings({ validateUrls })}
|
export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
|
||||||
>
|
const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false };
|
||||||
By default, request validation on long URLs when creating new short URLs.
|
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">
|
<small className="form-text text-muted">
|
||||||
The initial state of the <b>Validate URL</b> checkbox will
|
{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}
|
||||||
be <b>{shortUrlCreation?.validateUrls ? 'checked' : 'unchecked'}</b>.
|
|
||||||
</small>
|
</small>
|
||||||
</ToggleSwitch>
|
</FormGroup>
|
||||||
</FormGroup>
|
</SimpleCard>
|
||||||
</SimpleCard>
|
);
|
||||||
);
|
};
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
|
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FormGroup } from 'reactstrap';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||||
import { changeThemeInMarkup, Theme } from '../utils/theme';
|
import { changeThemeInMarkup, Theme } from '../utils/theme';
|
||||||
|
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
|
||||||
|
import { capitalize } from '../utils/utils';
|
||||||
import { Settings, UiSettings } from './reducers/settings';
|
import { Settings, UiSettings } from './reducers/settings';
|
||||||
import './UserInterface.scss';
|
import './UserInterface.scss';
|
||||||
|
|
||||||
@@ -14,17 +17,28 @@ interface UserInterfaceProps {
|
|||||||
|
|
||||||
export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
|
export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
|
||||||
<SimpleCard title="User interface" className="h-100">
|
<SimpleCard title="User interface" className="h-100">
|
||||||
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
<FormGroup>
|
||||||
<ToggleSwitch
|
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
||||||
checked={ui?.theme === 'dark'}
|
<ToggleSwitch
|
||||||
onChange={(useDarkTheme) => {
|
checked={ui?.theme === 'dark'}
|
||||||
const theme: Theme = useDarkTheme ? 'dark' : 'light';
|
onChange={(useDarkTheme) => {
|
||||||
|
const theme: Theme = useDarkTheme ? 'dark' : 'light';
|
||||||
|
|
||||||
setUiSettings({ theme });
|
setUiSettings({ ...ui, theme });
|
||||||
changeThemeInMarkup(theme);
|
changeThemeInMarkup(theme);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Use dark theme.
|
Use dark theme.
|
||||||
</ToggleSwitch>
|
</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>
|
</SimpleCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const Visits: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
|
|||||||
<FormGroup className="mb-0">
|
<FormGroup className="mb-0">
|
||||||
<label>Default interval to load on visits sections:</label>
|
<label>Default interval to load on visits sections:</label>
|
||||||
<DateIntervalSelector
|
<DateIntervalSelector
|
||||||
|
allText="All visits"
|
||||||
active={settings.visits?.defaultInterval ?? 'last30Days'}
|
active={settings.visits?.defaultInterval ?? 'last30Days'}
|
||||||
onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
|
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.
|
* 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;
|
enabled: boolean;
|
||||||
interval?: number;
|
interval?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TagFilteringMode = 'startsWith' | 'includes';
|
||||||
|
|
||||||
export interface ShortUrlCreationSettings {
|
export interface ShortUrlCreationSettings {
|
||||||
validateUrls: boolean;
|
validateUrls: boolean;
|
||||||
|
tagFilteringMode?: TagFilteringMode;
|
||||||
|
forwardQuery?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TagsMode = 'cards' | 'list';
|
||||||
|
|
||||||
export interface UiSettings {
|
export interface UiSettings {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
tagsMode?: TagsMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VisitsSettings {
|
export interface VisitsSettings {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => (
|
|||||||
maxVisits: undefined,
|
maxVisits: undefined,
|
||||||
findIfExists: false,
|
findIfExists: false,
|
||||||
validateUrl: settings?.validateUrls ?? false,
|
validateUrl: settings?.validateUrls ?? false,
|
||||||
|
forwardQuery: settings?.forwardQuery ?? true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const CreateShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>, CreateShortUrlResult: FC<CreateShortUrlResultProps>) => ({
|
const CreateShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>, CreateShortUrlResult: FC<CreateShortUrlResultProps>) => ({
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSetting
|
|||||||
validUntil: shortUrl.meta.validUntil ?? undefined,
|
validUntil: shortUrl.meta.validUntil ?? undefined,
|
||||||
maxVisits: shortUrl.meta.maxVisits ?? undefined,
|
maxVisits: shortUrl.meta.maxVisits ?? undefined,
|
||||||
crawlable: shortUrl.crawlable,
|
crawlable: shortUrl.crawlable,
|
||||||
|
forwardQuery: shortUrl.forwardQuery,
|
||||||
validateUrl,
|
validateUrl,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||||
import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination';
|
import {
|
||||||
|
pageIsEllipsis,
|
||||||
|
keyForPage,
|
||||||
|
progressivePagination,
|
||||||
|
prettifyPageNumber,
|
||||||
|
NumberOrEllipsis,
|
||||||
|
} from '../utils/helpers/pagination';
|
||||||
import { ShlinkPaginator } from '../api/types';
|
import { ShlinkPaginator } from '../api/types';
|
||||||
import './Paginator.scss';
|
|
||||||
|
|
||||||
interface PaginatorProps {
|
interface PaginatorProps {
|
||||||
paginator?: ShlinkPaginator;
|
paginator?: ShlinkPaginator;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
currentQueryString?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
const Paginator = ({ paginator, serverId, currentQueryString = '' }: PaginatorProps) => {
|
||||||
const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
|
const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
|
||||||
|
const urlForPage = (pageNumber: NumberOrEllipsis) =>
|
||||||
|
`/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`;
|
||||||
|
|
||||||
if (pagesCount <= 1) {
|
if (pagesCount <= 1) {
|
||||||
return null;
|
return null;
|
||||||
@@ -23,31 +31,20 @@ const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
|||||||
disabled={pageIsEllipsis(pageNumber)}
|
disabled={pageIsEllipsis(pageNumber)}
|
||||||
active={currentPage === pageNumber}
|
active={currentPage === pageNumber}
|
||||||
>
|
>
|
||||||
<PaginationLink
|
<PaginationLink tag={Link} to={urlForPage(pageNumber)}>
|
||||||
tag={Link}
|
|
||||||
to={`/server/${serverId}/list-short-urls/${pageNumber}`}
|
|
||||||
>
|
|
||||||
{prettifyPageNumber(pageNumber)}
|
{prettifyPageNumber(pageNumber)}
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
));
|
));
|
||||||
|
|
||||||
return (
|
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}>
|
<PaginationItem disabled={currentPage === 1}>
|
||||||
<PaginationLink
|
<PaginationLink previous tag={Link} to={urlForPage(currentPage - 1)} />
|
||||||
previous
|
|
||||||
tag={Link}
|
|
||||||
to={`/server/${serverId}/list-short-urls/${currentPage - 1}`}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
{renderPages()}
|
{renderPages()}
|
||||||
<PaginationItem disabled={currentPage >= pagesCount}>
|
<PaginationItem disabled={currentPage >= pagesCount}>
|
||||||
<PaginationLink
|
<PaginationLink next tag={Link} to={urlForPage(currentPage + 1)} />
|
||||||
next
|
|
||||||
tag={Link}
|
|
||||||
to={`/server/${serverId}/list-short-urls/${currentPage + 1}`}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</Pagination>
|
</Pagination>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,39 +2,43 @@ import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { isEmpty, pipe } from 'ramda';
|
import { isEmpty, pipe } from 'ramda';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
|
import { RouteChildrenProps } from 'react-router-dom';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import Tag from '../tags/helpers/Tag';
|
import Tag from '../tags/helpers/Tag';
|
||||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||||
import { formatIsoDate } from '../utils/helpers/date';
|
import { formatIsoDate } from '../utils/helpers/date';
|
||||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||||
import { DateRange } from '../utils/dates/types';
|
import { DateRange } from '../utils/dates/types';
|
||||||
import { ShortUrlsListParams } from './reducers/shortUrlsListParams';
|
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
|
||||||
import './SearchBar.scss';
|
import './SearchBar.scss';
|
||||||
|
|
||||||
interface SearchBarProps {
|
export type SearchBarProps = RouteChildrenProps<ShortUrlListRouteParams>;
|
||||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
|
||||||
shortUrlsListParams: ShortUrlsListParams;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
|
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
|
||||||
|
|
||||||
const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrlsListParams }: SearchBarProps) => {
|
const SearchBar = (colorGenerator: ColorGenerator) => (props: SearchBarProps) => {
|
||||||
const selectedTags = shortUrlsListParams.tags ?? [];
|
const [{ search, tags, startDate, endDate }, toFirstPage ] = useShortUrlsQuery(props);
|
||||||
|
const selectedTags = tags?.split(',') ?? [];
|
||||||
const setDates = pipe(
|
const setDates = pipe(
|
||||||
({ startDate, endDate }: DateRange) => ({
|
({ startDate, endDate }: DateRange) => ({
|
||||||
startDate: formatIsoDate(startDate) ?? undefined,
|
startDate: formatIsoDate(startDate) ?? undefined,
|
||||||
endDate: formatIsoDate(endDate) ?? 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 (
|
return (
|
||||||
<div className="search-bar-container">
|
<div className="search-bar-container">
|
||||||
<SearchField
|
<SearchField initialValue={search} onChange={setSearch} />
|
||||||
onChange={
|
|
||||||
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
@@ -42,8 +46,8 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl
|
|||||||
<DateRangeSelector
|
<DateRangeSelector
|
||||||
defaultText="All short URLs"
|
defaultText="All short URLs"
|
||||||
initialDateRange={{
|
initialDateRange={{
|
||||||
startDate: dateOrNull(shortUrlsListParams.startDate),
|
startDate: dateOrNull(startDate),
|
||||||
endDate: dateOrNull(shortUrlsListParams.endDate),
|
endDate: dateOrNull(endDate),
|
||||||
}}
|
}}
|
||||||
onDatesChange={setDates}
|
onDatesChange={setDates}
|
||||||
/>
|
/>
|
||||||
@@ -51,24 +55,12 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isEmpty(selectedTags) && (
|
{selectedTags.length > 0 && (
|
||||||
<h4 className="search-bar__selected-tag mt-3">
|
<h4 className="search-bar__selected-tag mt-3">
|
||||||
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
||||||
|
|
||||||
{selectedTags.map((tag) => (
|
{selectedTags.map((tag) =>
|
||||||
<Tag
|
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
|
||||||
colorGenerator={colorGenerator}
|
|
||||||
key={tag}
|
|
||||||
text={tag}
|
|
||||||
clearable
|
|
||||||
onClose={() => listShortUrls(
|
|
||||||
{
|
|
||||||
...shortUrlsListParams,
|
|
||||||
tags: selectedTags.filter((selectedTag) => selectedTag !== tag),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</h4>
|
</h4>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { InputType } from 'reactstrap/lib/Input';
|
import { InputType } from 'reactstrap/lib/Input';
|
||||||
import { Button, FormGroup, Input, Row } from 'reactstrap';
|
import { Button, FormGroup, Input, Row } from 'reactstrap';
|
||||||
import { isEmpty, pipe, replace, trim } from 'ramda';
|
import { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
import DateInput, { DateInputProps } from '../utils/DateInput';
|
import DateInput, { DateInputProps } from '../utils/DateInput';
|
||||||
import {
|
import { supportsCrawlableVisits, supportsForwardQuery, supportsShortUrlTitle } from '../utils/helpers/features';
|
||||||
supportsCrawlableVisits,
|
|
||||||
supportsListingDomains,
|
|
||||||
supportsSettingShortCodeLength,
|
|
||||||
supportsShortUrlTitle,
|
|
||||||
supportsValidateUrl,
|
|
||||||
} from '../utils/helpers/features';
|
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
|
import { handleEventPreventingDefault, hasValue, OptionalString } from '../utils/utils';
|
||||||
import Checkbox from '../utils/Checkbox';
|
import Checkbox from '../utils/Checkbox';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||||
@@ -39,21 +33,33 @@ export interface ShortUrlFormProps {
|
|||||||
|
|
||||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||||
const toDate = (date?: string | Date): Date | undefined => typeof date === 'string' ? parseISO(date) : date;
|
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 = (
|
export const ShortUrlForm = (
|
||||||
TagsSelector: FC<TagsSelectorProps>,
|
TagsSelector: FC<TagsSelectorProps>,
|
||||||
DomainSelector: FC<DomainSelectorProps>,
|
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 [ shortUrlData, setShortUrlData ] = useState(initialState);
|
||||||
const isEdit = mode === 'edit';
|
const isEdit = mode === 'edit';
|
||||||
|
const hadTitleOriginally = hasValue(initialState.title);
|
||||||
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
|
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
|
||||||
const reset = () => setShortUrlData(initialState);
|
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({
|
const submit = handleEventPreventingDefault(async () => onSave({
|
||||||
...shortUrlData,
|
...shortUrlData,
|
||||||
validSince: formatIsoDate(shortUrlData.validSince) ?? null,
|
validSince: formatIsoDate(shortUrlData.validSince) ?? null,
|
||||||
validUntil: formatIsoDate(shortUrlData.validUntil) ?? null,
|
validUntil: formatIsoDate(shortUrlData.validUntil) ?? null,
|
||||||
maxVisits: !hasValue(shortUrlData.maxVisits) ? null : Number(shortUrlData.maxVisits),
|
maxVisits: !hasValue(shortUrlData.maxVisits) ? null : Number(shortUrlData.maxVisits),
|
||||||
title: !hasValue(shortUrlData.title) ? undefined : shortUrlData.title,
|
title: resolveNewTitle(),
|
||||||
}).then(() => !isEdit && reset()).catch(() => {}));
|
}).then(() => !isEdit && reset()).catch(() => {}));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -102,17 +108,13 @@ export const ShortUrlForm = (
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const showDomainSelector = supportsListingDomains(selectedServer);
|
|
||||||
const disableShortCodeLength = !supportsSettingShortCodeLength(selectedServer);
|
|
||||||
const supportsTitle = supportsShortUrlTitle(selectedServer);
|
const supportsTitle = supportsShortUrlTitle(selectedServer);
|
||||||
const showCustomizeCard = supportsTitle || !isEdit;
|
const showCustomizeCard = supportsTitle || !isEdit;
|
||||||
const limitAccessCardClasses = classNames('mb-3', {
|
const limitAccessCardClasses = classNames('mb-3', dynamicColClasses(showCustomizeCard));
|
||||||
'col-sm-6': showCustomizeCard,
|
|
||||||
'col-sm-12': !showCustomizeCard,
|
|
||||||
});
|
|
||||||
const showValidateUrl = supportsValidateUrl(selectedServer);
|
|
||||||
const showCrawlableControl = supportsCrawlableVisits(selectedServer);
|
const showCrawlableControl = supportsCrawlableVisits(selectedServer);
|
||||||
const showExtraValidationsCard = showValidateUrl || showCrawlableControl || !isEdit;
|
const showForwardQueryControl = supportsForwardQuery(selectedServer);
|
||||||
|
const showBehaviorCard = showCrawlableControl || showForwardQueryControl;
|
||||||
|
const extraChecksCardClasses = classNames('mb-3', dynamicColClasses(showBehaviorCard));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="short-url-form" onSubmit={submit}>
|
<form className="short-url-form" onSubmit={submit}>
|
||||||
@@ -139,22 +141,16 @@ export const ShortUrlForm = (
|
|||||||
<div className="col-lg-6">
|
<div className="col-lg-6">
|
||||||
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
||||||
min: 4,
|
min: 4,
|
||||||
disabled: disableShortCodeLength || hasValue(shortUrlData.customSlug),
|
disabled: hasValue(shortUrlData.customSlug),
|
||||||
...disableShortCodeLength && {
|
|
||||||
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
|
|
||||||
},
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
{!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text')}
|
<FormGroup>
|
||||||
{showDomainSelector && (
|
<DomainSelector
|
||||||
<FormGroup>
|
value={shortUrlData.domain}
|
||||||
<DomainSelector
|
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
|
||||||
value={shortUrlData.domain}
|
/>
|
||||||
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
|
</FormGroup>
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
@@ -170,9 +166,9 @@ export const ShortUrlForm = (
|
|||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{showExtraValidationsCard && (
|
<Row>
|
||||||
<SimpleCard title="Extra checks" className="mb-3">
|
<div className={extraChecksCardClasses}>
|
||||||
{showValidateUrl && (
|
<SimpleCard title="Extra checks">
|
||||||
<ShortUrlFormCheckboxGroup
|
<ShortUrlFormCheckboxGroup
|
||||||
infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible."
|
infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible."
|
||||||
checked={shortUrlData.validateUrl}
|
checked={shortUrlData.validateUrl}
|
||||||
@@ -180,31 +176,46 @@ export const ShortUrlForm = (
|
|||||||
>
|
>
|
||||||
Validate URL
|
Validate URL
|
||||||
</ShortUrlFormCheckboxGroup>
|
</ShortUrlFormCheckboxGroup>
|
||||||
)}
|
{!isEdit && (
|
||||||
{showCrawlableControl && (
|
<p>
|
||||||
<ShortUrlFormCheckboxGroup
|
<Checkbox
|
||||||
infoTooltip="This short URL will be included in the robots.txt for your Shlink instance, allowing web crawlers (like Google) to index it."
|
inline
|
||||||
checked={shortUrlData.crawlable}
|
className="mr-2"
|
||||||
onChange={(crawlable) => setShortUrlData({ ...shortUrlData, crawlable })}
|
checked={shortUrlData.findIfExists}
|
||||||
>
|
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
|
||||||
Make it crawlable
|
>
|
||||||
</ShortUrlFormCheckboxGroup>
|
Use existing URL if found
|
||||||
)}
|
</Checkbox>
|
||||||
{!isEdit && (
|
<UseExistingIfFoundInfoIcon />
|
||||||
<p>
|
</p>
|
||||||
<Checkbox
|
)}
|
||||||
inline
|
</SimpleCard>
|
||||||
className="mr-2"
|
</div>
|
||||||
checked={shortUrlData.findIfExists}
|
{showBehaviorCard && (
|
||||||
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
|
<div className="col-sm-6 mb-3">
|
||||||
>
|
<SimpleCard title="Configure behavior">
|
||||||
Use existing URL if found
|
{showCrawlableControl && (
|
||||||
</Checkbox>
|
<ShortUrlFormCheckboxGroup
|
||||||
<UseExistingIfFoundInfoIcon />
|
infoTooltip="This short URL will be included in the robots.txt for your Shlink instance, allowing web crawlers (like Google) to index it."
|
||||||
</p>
|
checked={shortUrlData.crawlable}
|
||||||
)}
|
onChange={(crawlable) => setShortUrlData({ ...shortUrlData, crawlable })}
|
||||||
</SimpleCard>
|
>
|
||||||
)}
|
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 { head, keys, pipe, values } from 'ramda';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FC, useEffect, useMemo, useState } from 'react';
|
||||||
import { head, keys, values } from 'ramda';
|
|
||||||
import { FC, useEffect, useState } from 'react';
|
|
||||||
import { RouteComponentProps } from 'react-router';
|
import { RouteComponentProps } from 'react-router';
|
||||||
import { Card } from 'reactstrap';
|
import { Card } from 'reactstrap';
|
||||||
import SortingDropdown from '../utils/SortingDropdown';
|
import SortingDropdown from '../utils/SortingDropdown';
|
||||||
import { determineOrderDir, OrderDir } from '../utils/utils';
|
import { determineOrderDir, Order, OrderDir } from '../utils/helpers/ordering';
|
||||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
import { getServerId, SelectedServer } from '../servers/data';
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
import { parseQuery } from '../utils/helpers/query';
|
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
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 { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||||
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
|
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
|
||||||
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
||||||
import Paginator from './Paginator';
|
import Paginator from './Paginator';
|
||||||
import './ShortUrlsList.scss';
|
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
|
||||||
|
|
||||||
interface RouteParams {
|
interface ShortUrlsListProps extends RouteComponentProps<ShortUrlListRouteParams> {
|
||||||
page: string;
|
|
||||||
serverId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShortUrlsListProps extends RouteComponentProps<RouteParams> {
|
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
shortUrlsList: ShortUrlsListState;
|
shortUrlsList: ShortUrlsListState;
|
||||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
listShortUrls: (params: ShortUrlsListParams) => void;
|
||||||
@@ -29,76 +23,68 @@ export interface ShortUrlsListProps extends RouteComponentProps<RouteParams> {
|
|||||||
resetShortUrlParams: () => void;
|
resetShortUrlParams: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercureHub(({
|
type ShortUrlsOrder = Order<OrderableFields>;
|
||||||
|
|
||||||
|
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, SearchBar: FC) => boundToMercureHub(({
|
||||||
listShortUrls,
|
listShortUrls,
|
||||||
resetShortUrlParams,
|
resetShortUrlParams,
|
||||||
shortUrlsListParams,
|
shortUrlsListParams,
|
||||||
match,
|
match,
|
||||||
location,
|
location,
|
||||||
|
history,
|
||||||
shortUrlsList,
|
shortUrlsList,
|
||||||
selectedServer,
|
selectedServer,
|
||||||
}: ShortUrlsListProps) => {
|
}: ShortUrlsListProps) => {
|
||||||
|
const serverId = getServerId(selectedServer);
|
||||||
const { orderBy } = shortUrlsListParams;
|
const { orderBy } = shortUrlsListParams;
|
||||||
const [ order, setOrder ] = useState<{ orderField?: OrderableFields; orderDir?: OrderDir }>({
|
const [ order, setOrder ] = useState<ShortUrlsOrder>({
|
||||||
orderField: orderBy && (head(keys(orderBy)) as OrderableFields),
|
field: orderBy && (head(keys(orderBy)) as OrderableFields),
|
||||||
orderDir: orderBy && head(values(orderBy)),
|
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 { pagination } = shortUrlsList?.shortUrls ?? {};
|
||||||
const refreshList = (extraParams: ShortUrlsListParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
|
|
||||||
const handleOrderBy = (orderField?: OrderableFields, orderDir?: OrderDir) => {
|
const refreshList = (extraParams: ShlinkShortUrlsListParams) => listShortUrls(
|
||||||
setOrder({ orderField, orderDir });
|
{ ...shortUrlsListParams, ...extraParams },
|
||||||
refreshList({ orderBy: orderField ? { [orderField]: orderDir } : undefined });
|
);
|
||||||
|
const handleOrderBy = (field?: OrderableFields, dir?: OrderDir) => {
|
||||||
|
setOrder({ field, dir });
|
||||||
|
refreshList({ orderBy: field ? { [field]: dir } : undefined });
|
||||||
};
|
};
|
||||||
const orderByColumn = (field: OrderableFields) => () =>
|
const orderByColumn = (field: OrderableFields) => () =>
|
||||||
handleOrderBy(field, determineOrderDir(field, order.orderField, order.orderDir));
|
handleOrderBy(field, determineOrderDir(field, order.field, order.dir));
|
||||||
const renderOrderIcon = (field: OrderableFields) => {
|
const renderOrderIcon = (field: OrderableFields) => <TableOrderIcon currentOrder={order} field={field} />;
|
||||||
if (order.orderField !== field) {
|
const addTag = pipe(
|
||||||
return null;
|
(newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','),
|
||||||
}
|
(tags) => toFirstPage({ tags }),
|
||||||
|
);
|
||||||
if (!order.orderDir) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={order.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
|
|
||||||
className="short-urls-list__header-icon"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
useEffect(() => resetShortUrlParams, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { tag } = parseQuery<{ tag?: string }>(location.search);
|
refreshList(
|
||||||
const tags = tag ? [ decodeURIComponent(tag) ] : shortUrlsListParams.tags;
|
{ page: match.params.page, searchTerm: search, tags: selectedTags, itemsPerPage: undefined, startDate, endDate },
|
||||||
|
);
|
||||||
refreshList({ page: match.params.page, tags, itemsPerPage: undefined });
|
}, [ match.params.page, search, selectedTags, startDate, endDate ]);
|
||||||
|
|
||||||
return resetShortUrlParams;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="mb-3"><SearchBar /></div>
|
||||||
<div className="d-block d-lg-none mb-3">
|
<div className="d-block d-lg-none mb-3">
|
||||||
<SortingDropdown
|
<SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={handleOrderBy} />
|
||||||
items={SORTABLE_FIELDS}
|
|
||||||
orderField={order.orderField}
|
|
||||||
orderDir={order.orderDir}
|
|
||||||
onChange={handleOrderBy}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Card body className="pb-1">
|
<Card body className="pb-1">
|
||||||
<ShortUrlsTable
|
<ShortUrlsTable
|
||||||
orderByColumn={orderByColumn}
|
|
||||||
renderOrderIcon={renderOrderIcon}
|
|
||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
shortUrlsList={shortUrlsList}
|
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>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, () => [ Topics.visits() ]);
|
}, () => [ Topics.visits ]);
|
||||||
|
|
||||||
export default ShortUrlsList;
|
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 {
|
.short-urls-table__header-cell--with-action {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -60,37 +62,32 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<table className={tableClasses}>
|
<table className={tableClasses}>
|
||||||
<thead className="short-urls-table__header">
|
<thead className="responsive-table__header short-urls-table__header">
|
||||||
<tr>
|
<tr>
|
||||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
|
||||||
Created at
|
Created at {renderOrderIcon?.('dateCreated')}
|
||||||
{renderOrderIcon?.('dateCreated')}
|
|
||||||
</th>
|
</th>
|
||||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
|
||||||
Short URL
|
Short URL {renderOrderIcon?.('shortCode')}
|
||||||
{renderOrderIcon?.('shortCode')}
|
|
||||||
</th>
|
</th>
|
||||||
{!supportsTitle && (
|
{!supportsTitle && (
|
||||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}>
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}>
|
||||||
Long URL
|
Long URL {renderOrderIcon?.('longUrl')}
|
||||||
{renderOrderIcon?.('longUrl')}
|
|
||||||
</th>
|
</th>
|
||||||
) || (
|
) || (
|
||||||
<th className="short-urls-table__header-cell">
|
<th className="short-urls-table__header-cell">
|
||||||
<span className={actionableFieldClasses} onClick={orderByColumn?.('title')}>
|
<span className={actionableFieldClasses} onClick={orderByColumn?.('title')}>
|
||||||
Title
|
Title {renderOrderIcon?.('title')}
|
||||||
{renderOrderIcon?.('title')}
|
|
||||||
</span>
|
</span>
|
||||||
/
|
/
|
||||||
<span className={actionableFieldClasses} onClick={orderByColumn?.('longUrl')}>
|
<span className={actionableFieldClasses} onClick={orderByColumn?.('longUrl')}>
|
||||||
<span className="indivisible">Long URL</span>
|
<span className="indivisible">Long URL</span> {renderOrderIcon?.('longUrl')}
|
||||||
{renderOrderIcon?.('longUrl')}
|
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
<th className="short-urls-table__header-cell">Tags</th>
|
<th className="short-urls-table__header-cell">Tags</th>
|
||||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
|
||||||
<span className="indivisible">Visits{renderOrderIcon?.('visits')}</span>
|
<span className="indivisible">Visits {renderOrderIcon?.('visits')}</span>
|
||||||
</th>
|
</th>
|
||||||
<th className="short-urls-table__header-cell"> </th>
|
<th className="short-urls-table__header-cell"> </th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { Nullable, OptionalString } from '../../utils/utils';
|
|||||||
export interface EditShortUrlData {
|
export interface EditShortUrlData {
|
||||||
longUrl?: string;
|
longUrl?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
title?: string;
|
title?: string | null;
|
||||||
validSince?: Date | string | null;
|
validSince?: Date | string | null;
|
||||||
validUntil?: Date | string | null;
|
validUntil?: Date | string | null;
|
||||||
maxVisits?: number | null;
|
maxVisits?: number | null;
|
||||||
validateUrl?: boolean;
|
validateUrl?: boolean;
|
||||||
crawlable?: boolean;
|
crawlable?: boolean;
|
||||||
|
forwardQuery?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShortUrlData extends EditShortUrlData {
|
export interface ShortUrlData extends EditShortUrlData {
|
||||||
@@ -30,6 +31,7 @@ export interface ShortUrl {
|
|||||||
domain: string | null;
|
domain: string | null;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
crawlable?: boolean;
|
crawlable?: boolean;
|
||||||
|
forwardQuery?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShortUrlMeta {
|
export interface ShortUrlMeta {
|
||||||
|
|||||||
@@ -1,31 +1,44 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { FC, useMemo, useState } from 'react';
|
||||||
import { Modal, DropdownItem, FormGroup, ModalBody, ModalHeader, Row } from 'reactstrap';
|
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 { ExternalLink } from 'react-external-link';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { ShortUrlModalProps } from '../data';
|
import { ShortUrlModalProps } from '../data';
|
||||||
import { SelectedServer } from '../../servers/data';
|
import { SelectedServer } from '../../servers/data';
|
||||||
import { DropdownBtn } from '../../utils/DropdownBtn';
|
|
||||||
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
||||||
import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat } from '../../utils/helpers/qrCodes';
|
import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
|
||||||
import { supportsQrCodeSizeInQuery, supportsQrCodeSvgFormat, supportsQrCodeMargin } from '../../utils/helpers/features';
|
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 './QrCodeModal.scss';
|
||||||
|
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
|
||||||
|
|
||||||
interface QrCodeModalConnectProps extends ShortUrlModalProps {
|
interface QrCodeModalConnectProps extends ShortUrlModalProps {
|
||||||
selectedServer: SelectedServer;
|
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 [ size, setSize ] = useState(300);
|
||||||
const [ margin, setMargin ] = useState(0);
|
const [ margin, setMargin ] = useState(0);
|
||||||
const [ format, setFormat ] = useState<QrCodeFormat>('png');
|
const [ format, setFormat ] = useState<QrCodeFormat>('png');
|
||||||
|
const [ errorCorrection, setErrorCorrection ] = useState<QrErrorCorrection>('L');
|
||||||
const capabilities: QrCodeCapabilities = useMemo(() => ({
|
const capabilities: QrCodeCapabilities = useMemo(() => ({
|
||||||
useSizeInPath: !supportsQrCodeSizeInQuery(selectedServer),
|
useSizeInPath: !supportsQrCodeSizeInQuery(selectedServer),
|
||||||
svgIsSupported: supportsQrCodeSvgFormat(selectedServer),
|
|
||||||
marginIsSupported: supportsQrCodeMargin(selectedServer),
|
marginIsSupported: supportsQrCodeMargin(selectedServer),
|
||||||
|
errorCorrectionIsSupported: supportsQrErrorCorrection(selectedServer),
|
||||||
}), [ selectedServer ]);
|
}), [ selectedServer ]);
|
||||||
|
const willRenderThreeControls = capabilities.marginIsSupported !== capabilities.errorCorrectionIsSupported;
|
||||||
const qrCodeUrl = useMemo(
|
const qrCodeUrl = useMemo(
|
||||||
() => buildQrCodeUrl(shortUrl, { size, format, margin }, capabilities),
|
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities),
|
||||||
[ shortUrl, size, format, margin, capabilities ],
|
[ shortUrl, size, format, margin, errorCorrection, capabilities ],
|
||||||
);
|
);
|
||||||
const totalSize = useMemo(() => size + margin, [ size, margin ]);
|
const totalSize = useMemo(() => size + margin, [ size, margin ]);
|
||||||
const modalSize = useMemo(() => {
|
const modalSize = useMemo(() => {
|
||||||
@@ -42,60 +55,61 @@ const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }:
|
|||||||
QR code for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
|
QR code for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<Row className="mb-2">
|
<Row>
|
||||||
<div
|
<FormGroup
|
||||||
className={classNames({
|
className={classNames({ 'col-md-4': willRenderThreeControls, 'col-md-6': !willRenderThreeControls })}
|
||||||
'col-md-4': capabilities.marginIsSupported && capabilities.svgIsSupported,
|
|
||||||
'col-md-6': (!capabilities.marginIsSupported && capabilities.svgIsSupported) || (capabilities.marginIsSupported && !capabilities.svgIsSupported),
|
|
||||||
'col-12': !capabilities.marginIsSupported && !capabilities.svgIsSupported,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<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
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
className="form-control-range"
|
className="form-control-range"
|
||||||
value={size}
|
value={margin}
|
||||||
step={10}
|
step={1}
|
||||||
min={50}
|
min={0}
|
||||||
max={1000}
|
max={100}
|
||||||
onChange={(e) => setSize(Number(e.target.value))}
|
onChange={(e) => setMargin(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</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 && (
|
<FormGroup className={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}>
|
||||||
<div className={capabilities.marginIsSupported ? 'col-md-4' : 'col-md-6'}>
|
<QrFormatDropdown format={format} setFormat={setFormat} />
|
||||||
<DropdownBtn text={`Format (${format})`}>
|
</FormGroup>
|
||||||
<DropdownItem active={format === 'png'} onClick={() => setFormat('png')}>PNG</DropdownItem>
|
{capabilities.errorCorrectionIsSupported && (
|
||||||
<DropdownItem active={format === 'svg'} onClick={() => setFormat('svg')}>SVG</DropdownItem>
|
<FormGroup className="col-md-6">
|
||||||
</DropdownBtn>
|
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} setErrorCorrection={setErrorCorrection} />
|
||||||
</div>
|
</FormGroup>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div>QR code URL:</div>
|
|
||||||
<ExternalLink href={qrCodeUrl} />
|
<ExternalLink href={qrCodeUrl} />
|
||||||
<CopyToClipboardIcon text={qrCodeUrl} />
|
<CopyToClipboardIcon text={qrCodeUrl} />
|
||||||
</div>
|
</div>
|
||||||
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />
|
<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>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { ChangeEvent, FC, useRef } from 'react';
|
import { ChangeEvent, FC } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
|
||||||
import Checkbox from '../../utils/Checkbox';
|
import Checkbox from '../../utils/Checkbox';
|
||||||
|
import { InfoTooltip } from '../../utils/InfoTooltip';
|
||||||
|
|
||||||
interface ShortUrlFormCheckboxGroupProps {
|
interface ShortUrlFormCheckboxGroupProps {
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
@@ -10,23 +8,6 @@ interface ShortUrlFormCheckboxGroupProps {
|
|||||||
infoTooltip?: string;
|
infoTooltip?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const InfoTooltip: FC<{ tooltip: string }> = ({ tooltip }) => {
|
|
||||||
const ref = useRef<HTMLElement | null>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
ref={(el) => {
|
|
||||||
ref.current = el;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={infoIcon} />
|
|
||||||
</span>
|
|
||||||
<UncontrolledTooltip target={(() => ref.current) as any} placement="right">{tooltip}</UncontrolledTooltip>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
|
export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
|
||||||
{ children, infoTooltip, checked, onChange },
|
{ children, infoTooltip, checked, onChange },
|
||||||
) => (
|
) => (
|
||||||
@@ -34,6 +15,6 @@ export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
|
|||||||
<Checkbox inline checked={checked} className={infoTooltip ? 'mr-2' : ''} onChange={onChange}>
|
<Checkbox inline checked={checked} className={infoTooltip ? 'mr-2' : ''} onChange={onChange}>
|
||||||
{children}
|
{children}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
{infoTooltip && <InfoTooltip tooltip={infoTooltip} />}
|
{infoTooltip && <InfoTooltip placement="right">{infoTooltip}</InfoTooltip>}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,39 +1,8 @@
|
|||||||
@import '../../utils/base';
|
@import '../../utils/base';
|
||||||
@import '../../utils/mixins/vertical-align';
|
@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 {
|
.short-urls-row__cell.short-urls-row__cell {
|
||||||
vertical-align: middle !important;
|
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 {
|
.short-urls-row__cell--break {
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ const ShortUrlsRow = (
|
|||||||
}, [ shortUrl.visitsCount ]);
|
}, [ shortUrl.visitsCount ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="short-urls-row">
|
<tr className="responsive-table__row">
|
||||||
<td className="indivisible short-urls-row__cell" data-th="Created at: ">
|
<td className="indivisible short-urls-row__cell responsive-table__cell" data-th="Created at">
|
||||||
<Time date={shortUrl.dateCreated} />
|
<Time date={shortUrl.dateCreated} />
|
||||||
</td>
|
</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">
|
<span className="indivisible short-urls-row__cell--relative">
|
||||||
<ExternalLink href={shortUrl.shortUrl} />
|
<ExternalLink href={shortUrl.shortUrl} />
|
||||||
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
|
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
|
||||||
@@ -64,16 +64,16 @@ const ShortUrlsRow = (
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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>
|
<ExternalLink href={shortUrl.longUrl}>{shortUrl.title ?? shortUrl.longUrl}</ExternalLink>
|
||||||
</td>
|
</td>
|
||||||
{shortUrl.title && (
|
{shortUrl.title && (
|
||||||
<td className="short-urls-row__cell short-urls-row__cell--break 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} />
|
<ExternalLink href={shortUrl.longUrl} />
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="short-urls-row__cell" data-th="Tags: ">{renderTags(shortUrl.tags)}</td>
|
<td className="responsive-table__cell 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 text-lg-right" data-th="Visits">
|
||||||
<ShortUrlVisitsCount
|
<ShortUrlVisitsCount
|
||||||
visitsCount={shortUrl.visitsCount}
|
visitsCount={shortUrl.visitsCount}
|
||||||
shortUrl={shortUrl}
|
shortUrl={shortUrl}
|
||||||
@@ -81,7 +81,7 @@ const ShortUrlsRow = (
|
|||||||
active={active}
|
active={active}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="short-urls-row__cell">
|
<td className="responsive-table__cell short-urls-row__cell">
|
||||||
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
|
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 {
|
import {
|
||||||
faChartPie as pieChartIcon,
|
faChartPie as pieChartIcon,
|
||||||
faEllipsisV as menuIcon,
|
|
||||||
faQrcode as qrIcon,
|
faQrcode as qrIcon,
|
||||||
faMinusCircle as deleteIcon,
|
faMinusCircle as deleteIcon,
|
||||||
faEdit as editIcon,
|
faEdit as editIcon,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
|
import { DropdownItem } from 'reactstrap';
|
||||||
import { useToggle } from '../../utils/helpers/hooks';
|
import { useToggle } from '../../utils/helpers/hooks';
|
||||||
import { ShortUrl, ShortUrlModalProps } from '../data';
|
import { ShortUrl, ShortUrlModalProps } from '../data';
|
||||||
import { SelectedServer } from '../../servers/data';
|
import { SelectedServer } from '../../servers/data';
|
||||||
|
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
||||||
import ShortUrlDetailLink from './ShortUrlDetailLink';
|
import ShortUrlDetailLink from './ShortUrlDetailLink';
|
||||||
import './ShortUrlsRowMenu.scss';
|
|
||||||
|
|
||||||
export interface ShortUrlsRowMenuProps {
|
export interface ShortUrlsRowMenuProps {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
@@ -29,32 +28,27 @@ const ShortUrlsRowMenu = (
|
|||||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ButtonDropdown toggle={toggle} isOpen={isOpen}>
|
<DropdownBtnMenu toggle={toggle} isOpen={isOpen}>
|
||||||
<DropdownToggle size="sm" caret outline className="short-urls-row-menu__dropdown-toggle">
|
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
|
||||||
<FontAwesomeIcon icon={menuIcon} />
|
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
||||||
</DropdownToggle>
|
</DropdownItem>
|
||||||
<DropdownMenu right>
|
|
||||||
<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">
|
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="edit">
|
||||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
|
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
|
||||||
<DropdownItem onClick={toggleQrCode}>
|
<DropdownItem onClick={toggleQrCode}>
|
||||||
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
|
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
|
||||||
|
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
|
|
||||||
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
<DropdownItem className="dropdown-item--danger" onClick={toggleDelete}>
|
||||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
|
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
|
||||||
</DropdownMenu>
|
</DropdownBtnMenu>
|
||||||
</ButtonDropdown>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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 { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
import { ProblemDetailsError } from '../../api/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
|
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
|
||||||
@@ -24,17 +25,13 @@ export interface CreateShortUrlAction extends Action<string> {
|
|||||||
result: ShortUrl;
|
result: ShortUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateShortUrlFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ShortUrlCreation = {
|
const initialState: ShortUrlCreation = {
|
||||||
result: null,
|
result: null,
|
||||||
saving: false,
|
saving: false,
|
||||||
error: 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_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||||
[CREATE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
[CREATE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
||||||
[CREATE_SHORT_URL]: (_, { result }) => ({ result, saving: false, error: false }),
|
[CREATE_SHORT_URL]: (_, { result }) => ({ result, saving: false, error: false }),
|
||||||
@@ -52,8 +49,8 @@ export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
|||||||
const result = await createShortUrl(data);
|
const result = await createShortUrl(data);
|
||||||
|
|
||||||
dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result });
|
dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch<CreateShortUrlFailedAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ProblemDetailsError } from '../../api/types';
|
|||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
|
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;
|
domain?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeleteShortUrlErrorAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ShortUrlDeletion = {
|
const initialState: ShortUrlDeletion = {
|
||||||
shortCode: '',
|
shortCode: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
error: 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_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||||
[DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
|
[DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
|
||||||
[SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
|
[SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
|
||||||
@@ -51,8 +48,8 @@ export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
|||||||
try {
|
try {
|
||||||
await deleteShortUrl(shortCode, domain);
|
await deleteShortUrl(shortCode, domain);
|
||||||
dispatch<DeleteShortUrlAction>({ type: SHORT_URL_DELETED, shortCode, domain });
|
dispatch<DeleteShortUrlAction>({ type: SHORT_URL_DELETED, shortCode, domain });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch<DeleteShortUrlErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { GetState } from '../../container/types';
|
|||||||
import { shortUrlMatches } from '../helpers';
|
import { shortUrlMatches } from '../helpers';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
import { ProblemDetailsError } from '../../api/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START';
|
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;
|
shortUrl: ShortUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShortUrlDetailFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ShortUrlDetail = {
|
const initialState: ShortUrlDetail = {
|
||||||
loading: false,
|
loading: false,
|
||||||
error: 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_START]: () => ({ loading: true, error: false }),
|
||||||
[GET_SHORT_URL_DETAIL_ERROR]: (_, { errorData }) => ({ loading: false, error: true, errorData }),
|
[GET_SHORT_URL_DETAIL_ERROR]: (_, { errorData }) => ({ loading: false, error: true, errorData }),
|
||||||
[GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }),
|
[GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }),
|
||||||
@@ -53,7 +50,7 @@ export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder)
|
|||||||
) ?? await buildShlinkApiClient(getState).getShortUrl(shortCode, domain);
|
) ?? await buildShlinkApiClient(getState).getShortUrl(shortCode, domain);
|
||||||
|
|
||||||
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });
|
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch<ShortUrlDetailFailedAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde
|
|||||||
import { ProblemDetailsError } from '../../api/types';
|
import { ProblemDetailsError } from '../../api/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
import { supportsTagsInPatch } from '../../utils/helpers/features';
|
import { supportsTagsInPatch } from '../../utils/helpers/features';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
|
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
|
||||||
@@ -25,16 +26,12 @@ export interface ShortUrlEditedAction extends Action<string> {
|
|||||||
shortUrl: ShortUrl;
|
shortUrl: ShortUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShortUrlEditionFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ShortUrlEdition = {
|
const initialState: ShortUrlEdition = {
|
||||||
saving: false,
|
saving: false,
|
||||||
error: 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_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||||
[EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
[EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
||||||
[SHORT_URL_EDITED]: (_, { shortUrl }) => ({ shortUrl, saving: false, error: false }),
|
[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 });
|
dispatch<ShortUrlEditedAction>({ shortUrl, type: SHORT_URL_EDITED });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch<ShortUrlEditionFailedAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCr
|
|||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { buildReducer } from '../../utils/helpers/redux';
|
||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
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 { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
|
||||||
import { ShortUrlsListParams } from './shortUrlsListParams';
|
import { ShortUrlsListParams } from './shortUrlsListParams';
|
||||||
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
||||||
@@ -101,7 +101,7 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
|
|||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||||
params: ShortUrlsListParams = {},
|
params: ShlinkShortUrlsListParams = {},
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||||
dispatch({ type: LIST_SHORT_URLS_START });
|
dispatch({ type: LIST_SHORT_URLS_START });
|
||||||
const { listShortUrls } = buildShlinkApiClient(getState);
|
const { listShortUrls } = buildShlinkApiClient(getState);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
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';
|
import { LIST_SHORT_URLS, ListShortUrlsAction } from './shortUrlsList';
|
||||||
|
|
||||||
export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS';
|
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 OrderableFields = keyof typeof SORTABLE_FIELDS;
|
||||||
|
|
||||||
|
export type OrderBy = Partial<Record<OrderableFields, OrderDir>>;
|
||||||
|
|
||||||
export interface ShortUrlsListParams {
|
export interface ShortUrlsListParams {
|
||||||
page?: string;
|
page?: string;
|
||||||
itemsPerPage?: number;
|
itemsPerPage?: number;
|
||||||
tags?: string[];
|
orderBy?: OrderBy;
|
||||||
searchTerm?: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
orderBy?: Partial<Record<OrderableFields, OrderDir>>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ShortUrlsListParams = {
|
const initialState: ShortUrlsListParams = {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Bottle from 'bottlejs';
|
import Bottle, { Decorator } from 'bottlejs';
|
||||||
import ShortUrls from '../ShortUrls';
|
|
||||||
import SearchBar from '../SearchBar';
|
import SearchBar from '../SearchBar';
|
||||||
import ShortUrlsList from '../ShortUrlsList';
|
import ShortUrlsList from '../ShortUrlsList';
|
||||||
import ShortUrlsRow from '../helpers/ShortUrlsRow';
|
import ShortUrlsRow from '../helpers/ShortUrlsRow';
|
||||||
@@ -19,14 +18,11 @@ import { ShortUrlForm } from '../ShortUrlForm';
|
|||||||
import { EditShortUrl } from '../EditShortUrl';
|
import { EditShortUrl } from '../EditShortUrl';
|
||||||
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('ShortUrls', ShortUrls, 'SearchBar', 'ShortUrlsList');
|
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'SearchBar');
|
||||||
bottle.decorator('ShortUrls', connect([ 'shortUrlsList' ]));
|
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable');
|
|
||||||
bottle.decorator('ShortUrlsList', connect(
|
bottle.decorator('ShortUrlsList', connect(
|
||||||
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ],
|
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo', 'shortUrlsList' ],
|
||||||
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ],
|
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ],
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -51,12 +47,12 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||||||
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
|
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
|
||||||
bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ]));
|
bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('QrCodeModal', () => QrCodeModal);
|
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader', 'ForServerVersion');
|
||||||
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
|
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
|
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
|
||||||
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
|
bottle.decorator('SearchBar', withRouter);
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
||||||
|
|||||||
@@ -1,36 +1,41 @@
|
|||||||
import { Card, CardHeader, CardBody, Button, Collapse } from 'reactstrap';
|
import { Card, CardHeader, CardBody, Button, Collapse } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faTrash as deleteIcon, faPencilAlt as editIcon, faLink, faEye } from '@fortawesome/free-solid-svg-icons';
|
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 { Link } from 'react-router-dom';
|
||||||
import { prettify } from '../utils/helpers/numbers';
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import { Versions } from '../utils/helpers/version';
|
|
||||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||||
import { isServerWithId, SelectedServer } from '../servers/data';
|
import { getServerId, SelectedServer } from '../servers/data';
|
||||||
import TagBullet from './helpers/TagBullet';
|
import TagBullet from './helpers/TagBullet';
|
||||||
import { TagModalProps, TagStats } from './data';
|
import { NormalizedTag, TagModalProps } from './data';
|
||||||
import './TagCard.scss';
|
import './TagCard.scss';
|
||||||
|
|
||||||
export interface TagCardProps {
|
export interface TagCardProps {
|
||||||
tag: string;
|
tag: NormalizedTag;
|
||||||
tagStats?: TagStats;
|
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
displayed: boolean;
|
displayed: boolean;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isTruncated = (el: HTMLElement | undefined): boolean => !!el && el.scrollWidth > el.clientWidth;
|
||||||
|
|
||||||
const TagCard = (
|
const TagCard = (
|
||||||
DeleteTagConfirmModal: FC<TagModalProps>,
|
DeleteTagConfirmModal: FC<TagModalProps>,
|
||||||
EditTagModal: FC<TagModalProps>,
|
EditTagModal: FC<TagModalProps>,
|
||||||
ForServerVersion: FC<Versions>,
|
|
||||||
colorGenerator: ColorGenerator,
|
colorGenerator: ColorGenerator,
|
||||||
) => ({ tag, tagStats, selectedServer, displayed, toggle }: TagCardProps) => {
|
) => ({ tag, selectedServer, displayed, toggle }: TagCardProps) => {
|
||||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||||
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||||
|
const [ hasTitle,, displayTitle ] = useToggle();
|
||||||
|
const titleRef = useRef<HTMLElement>();
|
||||||
|
const serverId = getServerId(selectedServer);
|
||||||
|
|
||||||
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
useEffect(() => {
|
||||||
const shortUrlsLink = `/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag)}`;
|
if (isTruncated(titleRef.current)) {
|
||||||
|
displayTitle();
|
||||||
|
}
|
||||||
|
}, [ titleRef.current ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="tag-card">
|
<Card className="tag-card">
|
||||||
@@ -41,40 +46,39 @@ const TagCard = (
|
|||||||
<Button color="link" size="sm" className="tag-card__btn" onClick={toggleEdit}>
|
<Button color="link" size="sm" className="tag-card__btn" onClick={toggleEdit}>
|
||||||
<FontAwesomeIcon icon={editIcon} />
|
<FontAwesomeIcon icon={editIcon} />
|
||||||
</Button>
|
</Button>
|
||||||
<h5 className="tag-card__tag-title text-ellipsis">
|
<h5
|
||||||
<TagBullet tag={tag} colorGenerator={colorGenerator} />
|
className="tag-card__tag-title text-ellipsis"
|
||||||
<ForServerVersion minVersion="2.2.0">
|
title={hasTitle ? tag.tag : undefined}
|
||||||
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span>
|
ref={(el) => {
|
||||||
</ForServerVersion>
|
titleRef.current = el ?? undefined;
|
||||||
<ForServerVersion maxVersion="2.1.*">
|
}}
|
||||||
<Link to={shortUrlsLink}>{tag}</Link>
|
>
|
||||||
</ForServerVersion>
|
<TagBullet tag={tag.tag} colorGenerator={colorGenerator} />
|
||||||
|
<span className="tag-card__tag-name" onClick={toggle}>{tag.tag}</span>
|
||||||
</h5>
|
</h5>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{tagStats && (
|
<Collapse isOpen={displayed}>
|
||||||
<Collapse isOpen={displayed}>
|
<CardBody className="tag-card__body">
|
||||||
<CardBody className="tag-card__body">
|
<Link
|
||||||
<Link
|
to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}
|
||||||
to={shortUrlsLink}
|
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center mb-1"
|
||||||
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>
|
||||||
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="mr-2" />Short URLs</span>
|
<b>{prettify(tag.shortUrls)}</b>
|
||||||
<b>{prettify(tagStats.shortUrlsCount)}</b>
|
</Link>
|
||||||
</Link>
|
<Link
|
||||||
<Link
|
to={`/server/${serverId}/tag/${tag.tag}/visits`}
|
||||||
to={`/server/${serverId}/tag/${tag}/visits`}
|
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center"
|
||||||
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>
|
||||||
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
|
<b>{prettify(tag.visits)}</b>
|
||||||
<b>{prettify(tagStats.visitsCount)}</b>
|
</Link>
|
||||||
</Link>
|
</CardBody>
|
||||||
</CardBody>
|
</Collapse>
|
||||||
</Collapse>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
<DeleteTagConfirmModal tag={tag.tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
||||||
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
<EditTagModal tag={tag.tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
||||||
</Card>
|
</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 { FC, useEffect, useState } from 'react';
|
||||||
import { splitEvery } from 'ramda';
|
import { Row } from 'reactstrap';
|
||||||
|
import { pipe } from 'ramda';
|
||||||
import Message from '../utils/Message';
|
import Message from '../utils/Message';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
@@ -7,75 +8,92 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
|||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
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 { TagsList as TagsListState } from './reducers/tagsList';
|
||||||
import { TagCardProps } from './TagCard';
|
import { OrderableFields, SORTABLE_FIELDS, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps';
|
||||||
|
import { TagsModeDropdown } from './TagsModeDropdown';
|
||||||
const { ceil } = Math;
|
import { NormalizedTag } from './data';
|
||||||
const TAGS_GROUPS_AMOUNT = 4;
|
import { TagsTableProps } from './TagsTable';
|
||||||
|
|
||||||
export interface TagsListProps {
|
export interface TagsListProps {
|
||||||
filterTags: (searchTerm: string) => void;
|
filterTags: (searchTerm: string) => void;
|
||||||
forceListTags: Function;
|
forceListTags: Function;
|
||||||
tagsList: TagsListState;
|
tagsList: TagsListState;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
|
settings: Settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableProps>) => boundToMercureHub((
|
||||||
{ filterTags, forceListTags, tagsList, selectedServer }: TagsListProps,
|
{ 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(() => {
|
useEffect(() => {
|
||||||
forceListTags();
|
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 = () => {
|
const renderContent = () => {
|
||||||
if (tagsList.loading) {
|
if (tagsList.filteredTags.length < 1) {
|
||||||
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) {
|
|
||||||
return <Message>No tags found</Message>;
|
return <Message>No tags found</Message>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
const sortedTags = resolveSortedTags();
|
||||||
|
|
||||||
return (
|
return mode === 'cards'
|
||||||
<div className="row">
|
? <TagsCards sortedTags={sortedTags} selectedServer={selectedServer} />
|
||||||
{tagsGroups.map((group, index) => (
|
: (
|
||||||
<div key={index} className="col-md-6 col-xl-3">
|
<TagsTable
|
||||||
{group.map((tag) => (
|
sortedTags={sortedTags}
|
||||||
<TagCard
|
selectedServer={selectedServer}
|
||||||
key={tag}
|
currentOrder={order}
|
||||||
tag={tag}
|
orderByColumn={orderByColumn}
|
||||||
tagStats={tagsList.stats[tag]}
|
/>
|
||||||
selectedServer={selectedServer}
|
);
|
||||||
displayed={displayedTag === tag}
|
|
||||||
toggle={() => setDisplayedTag(displayedTag !== tag ? tag : undefined)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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()}
|
{renderContent()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, () => [ Topics.visits() ]);
|
}, () => [ Topics.visits ]);
|
||||||
|
|
||||||
export default TagsList;
|
export default TagsList;
|
||||||
|
|||||||
23
src/tags/TagsModeDropdown.tsx
Normal file
23
src/tags/TagsModeDropdown.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { DropdownItem } from 'reactstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faBars as listIcon, faThLarge as cardsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { DropdownBtn } from '../utils/DropdownBtn';
|
||||||
|
import { TagsMode } from '../settings/reducers/settings';
|
||||||
|
|
||||||
|
interface TagsModeDropdownProps {
|
||||||
|
mode: TagsMode;
|
||||||
|
onChange: (newMode: TagsMode) => void;
|
||||||
|
renderTitle?: (mode: TagsMode) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TagsModeDropdown: FC<TagsModeDropdownProps> = ({ mode, onChange, renderTitle }) => (
|
||||||
|
<DropdownBtn text={renderTitle?.(mode) ?? `Display mode: ${mode}`}>
|
||||||
|
<DropdownItem active={mode === 'cards'} onClick={() => onChange('cards')}>
|
||||||
|
<FontAwesomeIcon icon={cardsIcon} fixedWidth className="mr-1" /> Cards
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem active={mode === 'list'} onClick={() => onChange('list')}>
|
||||||
|
<FontAwesomeIcon icon={listIcon} fixedWidth className="mr-1" /> List
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownBtn>
|
||||||
|
);
|
||||||
10
src/tags/TagsTable.scss
Normal file
10
src/tags/TagsTable.scss
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
@import '../utils/base';
|
||||||
|
@import '../utils/mixins/sticky-cell';
|
||||||
|
|
||||||
|
.tags-table__header-cell.tags-table__header-cell {
|
||||||
|
@include sticky-cell(false);
|
||||||
|
|
||||||
|
top: $headerHeight;
|
||||||
|
position: sticky;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
69
src/tags/TagsTable.tsx
Normal file
69
src/tags/TagsTable.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { FC, useEffect, useRef } from 'react';
|
||||||
|
import { splitEvery } from 'ramda';
|
||||||
|
import { RouteChildrenProps } from 'react-router';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
import SimplePaginator from '../common/SimplePaginator';
|
||||||
|
import { useQueryState } from '../utils/helpers/hooks';
|
||||||
|
import { parseQuery } from '../utils/helpers/query';
|
||||||
|
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||||
|
import { OrderableFields, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps';
|
||||||
|
import { TagsTableRowProps } from './TagsTableRow';
|
||||||
|
import './TagsTable.scss';
|
||||||
|
|
||||||
|
export interface TagsTableProps extends TagsListChildrenProps {
|
||||||
|
orderByColumn: (field: OrderableFields) => () => void;
|
||||||
|
currentOrder: TagsOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
|
||||||
|
|
||||||
|
export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
|
||||||
|
{ sortedTags, selectedServer, location, orderByColumn, currentOrder }: TagsTableProps & RouteChildrenProps,
|
||||||
|
) => {
|
||||||
|
const isFirstLoad = useRef(true);
|
||||||
|
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(location.search);
|
||||||
|
const [ page, setPage ] = useQueryState<number>('page', Number(pageFromQuery));
|
||||||
|
const pages = splitEvery(TAGS_PER_PAGE, sortedTags);
|
||||||
|
const showPaginator = pages.length > 1;
|
||||||
|
const currentPage = pages[page - 1] ?? [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
!isFirstLoad.current && setPage(1);
|
||||||
|
isFirstLoad.current = false;
|
||||||
|
}, [ sortedTags ]);
|
||||||
|
useEffect(() => {
|
||||||
|
scrollTo(0, 0);
|
||||||
|
}, [ page ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleCard key={page} bodyClassName={showPaginator ? 'pb-1' : ''}>
|
||||||
|
<table className="table table-hover mb-0">
|
||||||
|
<thead className="responsive-table__header">
|
||||||
|
<tr>
|
||||||
|
<th className="tags-table__header-cell" onClick={orderByColumn('tag')}>
|
||||||
|
Tag <TableOrderIcon currentOrder={currentOrder} field="tag" />
|
||||||
|
</th>
|
||||||
|
<th className="tags-table__header-cell text-lg-right" onClick={orderByColumn('shortUrls')}>
|
||||||
|
Short URLs <TableOrderIcon currentOrder={currentOrder} field="shortUrls" />
|
||||||
|
</th>
|
||||||
|
<th className="tags-table__header-cell text-lg-right" onClick={orderByColumn('visits')}>
|
||||||
|
Visits <TableOrderIcon currentOrder={currentOrder} field="visits" />
|
||||||
|
</th>
|
||||||
|
<th className="tags-table__header-cell" />
|
||||||
|
</tr>
|
||||||
|
<tr><th colSpan={4} className="p-0 border-top-0" /></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{currentPage.length === 0 && <tr><td colSpan={4} className="text-center">No results found</td></tr>}
|
||||||
|
{currentPage.map((tag) => <TagsTableRow key={tag.tag} tag={tag} selectedServer={selectedServer} />)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{showPaginator && (
|
||||||
|
<div className="sticky-card-paginator">
|
||||||
|
<SimplePaginator pagesCount={pages.length} currentPage={page} setCurrentPage={setPage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SimpleCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
59
src/tags/TagsTableRow.tsx
Normal file
59
src/tags/TagsTableRow.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { DropdownItem } from 'reactstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faTrash as deleteIcon, faPencilAlt as editIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { getServerId, SelectedServer } from '../servers/data';
|
||||||
|
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||||
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
|
||||||
|
import TagBullet from './helpers/TagBullet';
|
||||||
|
import { NormalizedTag, TagModalProps } from './data';
|
||||||
|
|
||||||
|
export interface TagsTableRowProps {
|
||||||
|
tag: NormalizedTag;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TagsTableRow = (
|
||||||
|
DeleteTagConfirmModal: FC<TagModalProps>,
|
||||||
|
EditTagModal: FC<TagModalProps>,
|
||||||
|
colorGenerator: ColorGenerator,
|
||||||
|
) => ({ tag, selectedServer }: TagsTableRowProps) => {
|
||||||
|
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||||
|
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||||
|
const [ isDropdownOpen, toggleDropdown ] = useToggle();
|
||||||
|
const serverId = getServerId(selectedServer);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="responsive-table__row">
|
||||||
|
<th className="responsive-table__cell" data-th="Tag">
|
||||||
|
<TagBullet tag={tag.tag} colorGenerator={colorGenerator} /> {tag.tag}
|
||||||
|
</th>
|
||||||
|
<td className="responsive-table__cell text-lg-right" data-th="Short URLs">
|
||||||
|
<Link to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}>
|
||||||
|
{prettify(tag.shortUrls)}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="responsive-table__cell text-lg-right" data-th="Visits">
|
||||||
|
<Link to={`/server/${serverId}/tag/${tag.tag}/visits`}>
|
||||||
|
{prettify(tag.visits)}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="responsive-table__cell text-lg-right">
|
||||||
|
<DropdownBtnMenu toggle={toggleDropdown} isOpen={isDropdownOpen}>
|
||||||
|
<DropdownItem onClick={toggleEdit}>
|
||||||
|
<FontAwesomeIcon icon={editIcon} fixedWidth className="mr-1" /> Edit
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem onClick={toggleDelete}>
|
||||||
|
<FontAwesomeIcon icon={deleteIcon} fixedWidth className="mr-1" /> Delete
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownBtnMenu>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<EditTagModal tag={tag.tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
||||||
|
<DeleteTagConfirmModal tag={tag.tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
18
src/tags/data/TagsListChildrenProps.ts
Normal file
18
src/tags/data/TagsListChildrenProps.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { SelectedServer } from '../../servers/data';
|
||||||
|
import { Order } from '../../utils/helpers/ordering';
|
||||||
|
import { NormalizedTag } from './index';
|
||||||
|
|
||||||
|
export const SORTABLE_FIELDS = {
|
||||||
|
tag: 'Tag',
|
||||||
|
shortUrls: 'Short URLs',
|
||||||
|
visits: 'Visits',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrderableFields = keyof typeof SORTABLE_FIELDS;
|
||||||
|
|
||||||
|
export type TagsOrder = Order<OrderableFields>;
|
||||||
|
|
||||||
|
export interface TagsListChildrenProps {
|
||||||
|
sortedTags: NormalizedTag[];
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
}
|
||||||
@@ -8,3 +8,9 @@ export interface TagModalProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NormalizedTag {
|
||||||
|
tag: string;
|
||||||
|
shortUrls: number;
|
||||||
|
visits: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
import { Button, Input, Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
||||||
import { ChromePicker } from 'react-color';
|
import { ChromePicker } from 'react-color';
|
||||||
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
@@ -25,10 +25,12 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
|||||||
const [ color, setColor ] = useState(getColorForKey(tag));
|
const [ color, setColor ] = useState(getColorForKey(tag));
|
||||||
const [ showColorPicker, toggleColorPicker, , hideColorPicker ] = useToggle();
|
const [ showColorPicker, toggleColorPicker, , hideColorPicker ] = useToggle();
|
||||||
const { editing, error, errorData } = tagEdit;
|
const { editing, error, errorData } = tagEdit;
|
||||||
const saveTag = handleEventPreventingDefault(async () => editTag(tag, newTagName, color)
|
const saveTag = handleEventPreventingDefault(
|
||||||
.then(() => tagEdited(tag, newTagName, color))
|
async () => editTag(tag, newTagName, color)
|
||||||
.then(toggle)
|
.then(() => tagEdited(tag, newTagName, color))
|
||||||
.catch(() => {}));
|
.then(toggle)
|
||||||
|
.catch(() => {}),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={hideColorPicker}>
|
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={hideColorPicker}>
|
||||||
@@ -47,13 +49,11 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
|||||||
<Popover isOpen={showColorPicker} toggle={toggleColorPicker} target="colorPickerBtn" placement="right">
|
<Popover isOpen={showColorPicker} toggle={toggleColorPicker} target="colorPickerBtn" placement="right">
|
||||||
<ChromePicker color={color} disableAlpha onChange={({ hex }) => setColor(hex)} />
|
<ChromePicker color={color} disableAlpha onChange={({ hex }) => setColor(hex)} />
|
||||||
</Popover>
|
</Popover>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
|
||||||
value={newTagName}
|
value={newTagName}
|
||||||
placeholder="Tag"
|
placeholder="Tag"
|
||||||
required
|
required
|
||||||
className="form-control"
|
onChange={({ target }) => setNewTagName(target.value)}
|
||||||
onChange={(e) => setNewTagName(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,8 +64,8 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
|||||||
)}
|
)}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
|
<Button type="button" color="link" onClick={toggle}>Cancel</Button>
|
||||||
<button type="submit" className="btn btn-primary" disabled={editing}>{editing ? 'Saving...' : 'Save'}</button>
|
<Button color="primary" disabled={editing}>{editing ? 'Saving...' : 'Save'}</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import ReactTags, { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete';
|
import ReactTags, { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete';
|
||||||
import ColorGenerator from '../../utils/services/ColorGenerator';
|
import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||||
|
import { Settings } from '../../settings/reducers/settings';
|
||||||
import { TagsList } from '../reducers/tagsList';
|
import { TagsList } from '../reducers/tagsList';
|
||||||
import TagBullet from './TagBullet';
|
import TagBullet from './TagBullet';
|
||||||
import Tag from './Tag';
|
import Tag from './Tag';
|
||||||
@@ -14,17 +15,19 @@ export interface TagsSelectorProps {
|
|||||||
interface TagsSelectorConnectProps extends TagsSelectorProps {
|
interface TagsSelectorConnectProps extends TagsSelectorProps {
|
||||||
listTags: Function;
|
listTags: Function;
|
||||||
tagsList: TagsList;
|
tagsList: TagsList;
|
||||||
|
settings: Settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toComponentTag = (tag: string) => ({ id: tag, name: tag });
|
const toComponentTag = (tag: string) => ({ id: tag, name: tag });
|
||||||
|
|
||||||
const TagsSelector = (colorGenerator: ColorGenerator) => (
|
const TagsSelector = (colorGenerator: ColorGenerator) => (
|
||||||
{ selectedTags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }: TagsSelectorConnectProps,
|
{ selectedTags, onChange, placeholder, listTags, tagsList, settings }: TagsSelectorConnectProps,
|
||||||
) => {
|
) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listTags();
|
listTags();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const searchMode = settings.shortUrlCreation?.tagFilteringMode ?? 'startsWith';
|
||||||
const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) =>
|
const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) =>
|
||||||
<Tag colorGenerator={colorGenerator} text={tag.name} clearable className="react-tags__tag" onClose={onDelete} />;
|
<Tag colorGenerator={colorGenerator} text={tag.name} clearable className="react-tags__tag" onClose={onDelete} />;
|
||||||
const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => (
|
const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => (
|
||||||
@@ -42,15 +45,25 @@ const TagsSelector = (colorGenerator: ColorGenerator) => (
|
|||||||
suggestionComponent={ReactTagsSuggestion}
|
suggestionComponent={ReactTagsSuggestion}
|
||||||
allowNew
|
allowNew
|
||||||
addOnBlur
|
addOnBlur
|
||||||
placeholderText={placeholder}
|
placeholderText={placeholder ?? 'Add tags to the URL'}
|
||||||
minQueryLength={1}
|
minQueryLength={1}
|
||||||
|
delimiters={[ 'Enter', 'Tab', ',' ]}
|
||||||
|
suggestionsTransform={
|
||||||
|
searchMode === 'includes'
|
||||||
|
? (query, suggestions) => suggestions.filter(({ name }) => name.includes(query))
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onDelete={(removedTagIndex) => {
|
onDelete={(removedTagIndex) => {
|
||||||
const tagsCopy = [ ...selectedTags ];
|
const tagsCopy = [ ...selectedTags ];
|
||||||
|
|
||||||
tagsCopy.splice(removedTagIndex, 1);
|
tagsCopy.splice(removedTagIndex, 1);
|
||||||
onChange(tagsCopy);
|
onChange(tagsCopy);
|
||||||
}}
|
}}
|
||||||
onAddition={({ name: newTag }) => onChange([ ...selectedTags, newTag.toLowerCase() ])}
|
onAddition={({ name: newTag }) => onChange(
|
||||||
|
// * Avoid duplicated tags (thanks to the Set),
|
||||||
|
// * Split any of the new tags by comma, allowing to paste multiple comma-separated tags at once.
|
||||||
|
[ ...new Set([ ...selectedTags, ...newTag.toLowerCase().split(',') ]) ],
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { GetState } from '../../container/types';
|
|||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
import { ProblemDetailsError } from '../../api/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
||||||
@@ -22,16 +23,12 @@ export interface DeleteTagAction extends Action<string> {
|
|||||||
tag: string;
|
tag: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteTagFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: TagDeletion = {
|
const initialState: TagDeletion = {
|
||||||
deleting: false,
|
deleting: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<TagDeletion, DeleteTagFailedAction>({
|
export default buildReducer<TagDeletion, ApiErrorAction>({
|
||||||
[DELETE_TAG_START]: () => ({ deleting: true, error: false }),
|
[DELETE_TAG_START]: () => ({ deleting: true, error: false }),
|
||||||
[DELETE_TAG_ERROR]: (_, { errorData }) => ({ deleting: false, error: true, errorData }),
|
[DELETE_TAG_ERROR]: (_, { errorData }) => ({ deleting: false, error: true, errorData }),
|
||||||
[DELETE_TAG]: () => ({ deleting: false, error: false }),
|
[DELETE_TAG]: () => ({ deleting: false, error: false }),
|
||||||
@@ -47,8 +44,8 @@ export const deleteTag = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag:
|
|||||||
try {
|
try {
|
||||||
await deleteTags([ tag ]);
|
await deleteTags([ tag ]);
|
||||||
dispatch({ type: DELETE_TAG });
|
dispatch({ type: DELETE_TAG });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch<DeleteTagFailedAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ColorGenerator from '../../utils/services/ColorGenerator';
|
|||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
import { ProblemDetailsError } from '../../api/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
||||||
@@ -29,10 +30,6 @@ export interface EditTagAction extends Action<string> {
|
|||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditTagFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: TagEdition = {
|
const initialState: TagEdition = {
|
||||||
oldName: '',
|
oldName: '',
|
||||||
newName: '',
|
newName: '',
|
||||||
@@ -40,7 +37,7 @@ const initialState: TagEdition = {
|
|||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<TagEdition, EditTagAction & EditTagFailedAction>({
|
export default buildReducer<TagEdition, EditTagAction & ApiErrorAction>({
|
||||||
[EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }),
|
[EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }),
|
||||||
[EDIT_TAG_ERROR]: (state, { errorData }) => ({ ...state, editing: false, error: true, errorData }),
|
[EDIT_TAG_ERROR]: (state, { errorData }) => ({ ...state, editing: false, error: true, errorData }),
|
||||||
[EDIT_TAG]: (_, action) => ({
|
[EDIT_TAG]: (_, action) => ({
|
||||||
@@ -62,8 +59,8 @@ export const editTag = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGener
|
|||||||
await editTag(oldName, newName);
|
await editTag(oldName, newName);
|
||||||
colorGenerator.setColorForKey(newName, color);
|
colorGenerator.setColorForKey(newName, color);
|
||||||
dispatch({ type: EDIT_TAG, oldName, newName });
|
dispatch({ type: EDIT_TAG, oldName, newName });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch<EditTagFailedAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user