mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-03 06:47:29 +03:00
commit
8a7a51be2f
460 changed files with 7840 additions and 27931 deletions
|
@ -6,11 +6,5 @@
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"project": "./tsconfig.json"
|
"project": "./tsconfig.json"
|
||||||
},
|
},
|
||||||
"ignorePatterns": ["src/service*.ts"],
|
"ignorePatterns": ["src/service*.ts"]
|
||||||
"rules": {
|
|
||||||
"jsx-a11y/control-has-associated-label": "off",
|
|
||||||
"jsx-a11y/label-has-associated-control": "off",
|
|
||||||
"jsx-a11y/click-events-have-key-events": "off",
|
|
||||||
"jsx-a11y/no-static-element-interactions": "off"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
24
.github/DISCUSSION_TEMPLATE/q-a.yml
vendored
Normal file
24
.github/DISCUSSION_TEMPLATE/q-a.yml
vendored
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
title: 'Q&A'
|
||||||
|
body:
|
||||||
|
- type: input
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: shlink-web-client version
|
||||||
|
placeholder: x.y.z
|
||||||
|
- type: dropdown
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: How do you use shlink-web-client
|
||||||
|
options:
|
||||||
|
- https://app.shlink.io
|
||||||
|
- Docker image
|
||||||
|
- Self-hosted
|
||||||
|
- Other (explain in summary)
|
||||||
|
- type: textarea
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: Summary
|
||||||
|
value: '<!-- Describe your issue, question or request here. -->'
|
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
|
@ -1,2 +1,2 @@
|
||||||
github: ['acelaya']
|
github: ['acelaya']
|
||||||
custom: ['https://acel.me/donate']
|
custom: ['https://slnk.to/donate']
|
||||||
|
|
36
.github/ISSUE_TEMPLATE/Bug.md
vendored
36
.github/ISSUE_TEMPLATE/Bug.md
vendored
|
@ -1,36 +0,0 @@
|
||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Something on shlink is broken or not working as documented?
|
|
||||||
labels: bug
|
|
||||||
---
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
|
||||||
I'm always happy to help and provide support, but some understanding will be expected.
|
|
||||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
|
||||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
|
||||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
|
||||||
|
|
||||||
With that said, please fill in the information requested next. More information might be requested once the issue is open.
|
|
||||||
-->
|
|
||||||
|
|
||||||
#### Shlink web client version
|
|
||||||
|
|
||||||
* Version: x.y.z
|
|
||||||
* How do you use shlink-web-client: app.shlink.io|Docker image|self-hosted
|
|
||||||
|
|
||||||
#### Summary
|
|
||||||
|
|
||||||
<!-- Provide a summary describing the problem you are experiencing. -->
|
|
||||||
|
|
||||||
#### Current behavior
|
|
||||||
|
|
||||||
<!-- How is it actually behaving (and it shouldn't)? -->
|
|
||||||
|
|
||||||
#### Expected behavior
|
|
||||||
|
|
||||||
<!-- How did you expected to behave? -->
|
|
||||||
|
|
||||||
#### How to reproduce
|
|
||||||
|
|
||||||
<!-- Provide steps to reproduce the bug. -->
|
|
38
.github/ISSUE_TEMPLATE/Bug.yml
vendored
Normal file
38
.github/ISSUE_TEMPLATE/Bug.yml
vendored
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
name: 'Bug'
|
||||||
|
description: Something on shlink is broken or not working as documented?
|
||||||
|
labels: ['bug']
|
||||||
|
body:
|
||||||
|
- type: input
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: shlink-web-client version
|
||||||
|
placeholder: x.y.z
|
||||||
|
- type: dropdown
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: How do you use shlink-web-client
|
||||||
|
options:
|
||||||
|
- https://app.shlink.io
|
||||||
|
- Docker image
|
||||||
|
- Self-hosted
|
||||||
|
- Other (explain in summary)
|
||||||
|
- type: textarea
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: Current behavior
|
||||||
|
value: '<!-- How is it actually behaving (and it should not)? -->'
|
||||||
|
- type: textarea
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
value: '<!-- How did you expect it to behave? -->'
|
||||||
|
- type: textarea
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: How to reproduce
|
||||||
|
value: '<!-- Provide steps to reproduce the bug. -->'
|
19
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
19
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
|
@ -1,19 +0,0 @@
|
||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Do you find shlink is missing some important feature that would make it more useful?
|
|
||||||
labels: feature
|
|
||||||
---
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
|
||||||
I'm always happy to help and provide support, but some understanding will be expected.
|
|
||||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
|
||||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
|
||||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
|
||||||
|
|
||||||
With that said, please fill in the information requested next. More information might be requested once the issue is open.
|
|
||||||
-->
|
|
||||||
|
|
||||||
#### Summary
|
|
||||||
|
|
||||||
<!-- Describe the new feature you would like to request. -->
|
|
16
.github/ISSUE_TEMPLATE/Feature_Request.yml
vendored
Normal file
16
.github/ISSUE_TEMPLATE/Feature_Request.yml
vendored
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
name: Feature request
|
||||||
|
description: Do you find shlink-web-client is missing some important feature that would make it more useful?
|
||||||
|
labels: ['feature']
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: Summary
|
||||||
|
value: '<!-- Describe the new feature you would like to request. -->'
|
||||||
|
- type: textarea
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: Use case
|
||||||
|
value: '<!-- Explain why do you think this feature would be useful, and what problems would it help to solve. -->'
|
24
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
24
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
|
@ -1,24 +0,0 @@
|
||||||
---
|
|
||||||
name: Question - Support
|
|
||||||
about: Do you have a problem setting up or using shlink?
|
|
||||||
labels: question
|
|
||||||
---
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
|
||||||
I'm always happy to help and provide support, but some understanding will be expected.
|
|
||||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
|
||||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
|
||||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
|
||||||
|
|
||||||
With that said, please fill in the information requested next. More information might be requested once the issue is open.
|
|
||||||
-->
|
|
||||||
|
|
||||||
#### Shlink web client version
|
|
||||||
|
|
||||||
* Version: x.y.z
|
|
||||||
* How do you use shlink-web-client: app.shlink.io|Docker image|self-hosted
|
|
||||||
|
|
||||||
#### Summary
|
|
||||||
|
|
||||||
<!-- Describe the issue you are facing here. -->
|
|
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: Question - Support
|
||||||
|
about: Do you need help setting up or using shlink-web-client?
|
||||||
|
url: https://github.com/shlinkio/shlink-web-client/discussions/new?category=q-a
|
42
.github/dependabot.yml
vendored
Normal file
42
.github/dependabot.yml
vendored
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: '/'
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: saturday
|
||||||
|
time: '09:00'
|
||||||
|
timezone: 'Europe/Madrid'
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
groups:
|
||||||
|
fontawesome:
|
||||||
|
patterns:
|
||||||
|
- '@fortawesome/*'
|
||||||
|
shlink:
|
||||||
|
patterns:
|
||||||
|
- '@shlinkio/*'
|
||||||
|
types:
|
||||||
|
patterns:
|
||||||
|
- '@types/*'
|
||||||
|
testing:
|
||||||
|
patterns:
|
||||||
|
- '@testing-library/*'
|
||||||
|
vite:
|
||||||
|
patterns:
|
||||||
|
- 'vite'
|
||||||
|
- '@vitejs/*'
|
||||||
|
vitest:
|
||||||
|
patterns:
|
||||||
|
- 'vitest'
|
||||||
|
- '@vitest/*'
|
||||||
|
ignore:
|
||||||
|
# Bootstrap can introduce visual breaking changes on styles
|
||||||
|
# Ignore it, since the plan is to remove it anyway
|
||||||
|
- dependency-name: 'bootstrap'
|
||||||
|
- package-ecosystem: docker
|
||||||
|
directory: '/'
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: saturday
|
||||||
|
time: '09:00'
|
||||||
|
timezone: 'Europe/Madrid'
|
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
@ -11,6 +11,5 @@ jobs:
|
||||||
ci:
|
ci:
|
||||||
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
|
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
|
||||||
with:
|
with:
|
||||||
node-version: 20.2
|
node-version: 20.7
|
||||||
publish-coverage: true
|
publish-coverage: true
|
||||||
force-install: true
|
|
||||||
|
|
8
.github/workflows/deploy-preview.yml
vendored
8
.github/workflows/deploy-preview.yml
vendored
|
@ -5,7 +5,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
@ -16,11 +16,11 @@ jobs:
|
||||||
- name: Use node.js
|
- name: Use node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 20.2
|
node-version: 20.7
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
npm ci --force && \
|
npm ci && \
|
||||||
node ./scripts/set-homepage.js /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
|
node ./scripts/set-homepage.cjs /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
|
||||||
npm run build
|
npm run build
|
||||||
- name: Deploy preview
|
- name: Deploy preview
|
||||||
uses: shlinkio/deploy-preview-action@v1.0.1
|
uses: shlinkio/deploy-preview-action@v1.0.1
|
||||||
|
|
6
.github/workflows/publish-release.yml
vendored
6
.github/workflows/publish-release.yml
vendored
|
@ -7,16 +7,16 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
- name: Use node.js
|
- name: Use node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 20.2
|
node-version: 20.7
|
||||||
- name: Generate release assets
|
- name: Generate release assets
|
||||||
run: npm ci --force && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
|
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
|
||||||
- name: Publish release with assets
|
- name: Publish release with assets
|
||||||
uses: docker://antonyurchenko/git-release:latest
|
uses: docker://antonyurchenko/git-release:latest
|
||||||
env:
|
env:
|
||||||
|
|
30
CHANGELOG.md
30
CHANGELOG.md
|
@ -4,6 +4,36 @@ 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).
|
||||||
|
|
||||||
|
## [4.0.0] - 2024-01-29
|
||||||
|
### Added
|
||||||
|
* [shlink-web-component #7](https://github.com/shlinkio/shlink-web-component/issues/7) Allow comparing visits for multiple short URLs, tags or domains.
|
||||||
|
|
||||||
|
When in the tags, domains or short URLs tables, you can now pick up to 5 items to compare their visits. Once selected, you are taken to a section displaying a comparative line chart, which supports all regular visits filtering capabilities.
|
||||||
|
|
||||||
|
* [shlink-web-component #9](https://github.com/shlinkio/shlink-web-component/issues/9) Allow comparing visits with the previous period.
|
||||||
|
* [shlink-web-component #12](https://github.com/shlinkio/shlink-web-component/issues/12) and [#13](https://github.com/shlinkio/shlink-web-component/issues/13) Add new "Visits options" section for arbitrary visit stats options. Add section to delete short URL and orphan visits there.
|
||||||
|
|
||||||
|
This section is only visible if short URL visits deletion or orphan visits deletion are supported by connected Shlink server.
|
||||||
|
|
||||||
|
* [shlink-web-component #10](https://github.com/shlinkio/shlink-web-component/issues/10) Improve general accessibility: Add accessibility tests, fix accessibility issues and enable accessibility linting rules.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* [#338](https://github.com/shlinkio/shlink-web-client/issues/338) Extract `@shlinkio/shlink-web-component` and `@shlinkio/shlink-frontend-kit` as external libs.
|
||||||
|
* [#978](https://github.com/shlinkio/shlink-web-client/issues/978) Use system preferred theme as default theme.
|
||||||
|
* Use API client from `@shlinkio/shlink-js-sdk` to consume Shlink servers.
|
||||||
|
* [#902](https://github.com/shlinkio/shlink-web-client/pull/902) Docker image is no longer running as root. As a side effect, exposed port is `8080`, not `80` anymore.
|
||||||
|
* [shlink-web-component #117](https://github.com/shlinkio/shlink-web-component/issues/117) Migrate charts from Chart.JS to Recharts.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* Drop support for Shlink older than v3.0.0
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* [#910](https://github.com/shlinkio/shlink-web-client/issues/910) Fix warnings related with missing `act` in tests and refs in `AppUpdateBanner`.
|
||||||
|
|
||||||
|
|
||||||
## [3.10.2] - 2023-07-09
|
## [3.10.2] - 2023-07-09
|
||||||
### Added
|
### Added
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
|
@ -6,7 +6,7 @@ You will also see how to ensure the code fulfills the expected code checks, and
|
||||||
|
|
||||||
## System dependencies
|
## System dependencies
|
||||||
|
|
||||||
The project can be run inside a docker container through provided docker-compose configuration.
|
The project can be run inside a docker container through provided `docker compose` configuration.
|
||||||
|
|
||||||
Because of this, the only actual dependencies are [docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
|
Because of this, the only actual dependencies are [docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ The first thing you need to do is fork the repository, and clone it in your loca
|
||||||
Then you will have to follow these steps:
|
Then you will have to follow these steps:
|
||||||
|
|
||||||
* Copy the file `docker-compose.override.yml.dist` by also removing the `dist` extension.
|
* Copy the file `docker-compose.override.yml.dist` by also removing the `dist` extension.
|
||||||
* Start-up the project by running `docker-compose up`.
|
* Start-up the project by running `docker compose up`.
|
||||||
|
|
||||||
Once this is finished, you will have the project exposed in port `3000` (http://localhost:3000).
|
Once this is finished, you will have the project exposed in port `3000` (http://localhost:3000).
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
FROM node:20.2-alpine as node
|
FROM node:21.6-alpine as node
|
||||||
COPY . /shlink-web-client
|
COPY . /shlink-web-client
|
||||||
ARG VERSION="latest"
|
ARG VERSION="latest"
|
||||||
ENV VERSION ${VERSION}
|
ENV VERSION ${VERSION}
|
||||||
RUN cd /shlink-web-client && npm ci --force && npm run build
|
RUN cd /shlink-web-client && npm ci && npm run build
|
||||||
|
|
||||||
FROM nginx:1.23-alpine
|
FROM nginxinc/nginx-unprivileged:1.25-alpine
|
||||||
|
ARG UID=101
|
||||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||||
|
USER root
|
||||||
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
|
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
|
||||||
|
USER $UID
|
||||||
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY scripts/docker/servers_from_env.sh /docker-entrypoint.d/30-shlink-servers-json.sh
|
COPY scripts/docker/servers_from_env.sh /docker-entrypoint.d/30-shlink-servers-json.sh
|
||||||
COPY --from=node /shlink-web-client/build /usr/share/nginx/html
|
COPY --from=node /shlink-web-client/build /usr/share/nginx/html
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
[![GitHub release](https://img.shields.io/github/release/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
[![GitHub release](https://img.shields.io/github/release/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||||
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink-web-client.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink-web-client.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||||
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
||||||
[![Twitter](https://img.shields.io/badge/follow-shlinkio-blue.svg?style=flat-square&logo=twitter&color=blue)](https://twitter.com/shlinkio)
|
[![Twitter](https://img.shields.io/badge/follow-shlinkio-blue.svg?style=flat-square&logo=x&color=black)](https://twitter.com/shlinkio)
|
||||||
[![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio)
|
[![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio)
|
||||||
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate)
|
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate)
|
||||||
|
|
||||||
|
@ -69,7 +69,7 @@ Those servers can be exported and imported in other browsers, but if for some re
|
||||||
|
|
||||||
If you are using the shlink-web-client docker image, you can mount the `servers.json` file in a volume inside `/usr/share/nginx/html`, which is the app's document root inside the container.
|
If you are using the shlink-web-client docker image, you can mount the `servers.json` file in a volume inside `/usr/share/nginx/html`, which is the app's document root inside the container.
|
||||||
|
|
||||||
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/servers.json:/usr/share/nginx/html/servers.json shlinkio/shlink-web-client
|
docker run --name shlink-web-client -p 8000:8080 -v ${PWD}/servers.json:/usr/share/nginx/html/servers.json shlinkio/shlink-web-client
|
||||||
|
|
||||||
Alternatively, you can mount a `conf.d` directory, which in turn contains the `servers.json` file, in a volume inside `/usr/share/nginx/html`. *(since shlink-web-client 3.2.0)*.
|
Alternatively, you can mount a `conf.d` directory, which in turn contains the `servers.json` file, in a volume inside `/usr/share/nginx/html`. *(since shlink-web-client 3.2.0)*.
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
server {
|
server {
|
||||||
listen 80 default_server;
|
listen 8080 default_server;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
|
@ -1,27 +1,24 @@
|
||||||
import 'vitest-canvas-mock';
|
import '@testing-library/jest-dom/vitest';
|
||||||
import 'chart.js/auto';
|
|
||||||
import type { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers';
|
|
||||||
import matchers from '@testing-library/jest-dom/matchers';
|
|
||||||
import { cleanup } from '@testing-library/react';
|
import { cleanup } from '@testing-library/react';
|
||||||
import ResizeObserver from 'resize-observer-polyfill';
|
import axe from 'axe-core';
|
||||||
import { afterEach, expect } from 'vitest';
|
import { afterEach } from 'vitest';
|
||||||
|
|
||||||
// Workaround for TypeScript error: https://github.com/testing-library/jest-dom/issues/439#issuecomment-1536524120
|
axe.configure({
|
||||||
declare module 'vitest' {
|
checks: [
|
||||||
interface Assertion<T = any> extends jest.Matchers<void, T>, TestingLibraryMatchers<T, void> {}
|
{
|
||||||
}
|
// Disable color contrast checking, as it doesn't work in jsdom
|
||||||
|
id: 'color-contrast',
|
||||||
// Extends Vitest's expect method with methods from react-testing-library
|
enabled: false,
|
||||||
expect.extend(matchers);
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear all mocks and cleanup DOM after every test
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// Clears all mocks after every test
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Run a cleanup after each test case (e.g. clearing jsdom)
|
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
(global as any).ResizeObserver = ResizeObserver;
|
HTMLCanvasElement.prototype.getContext = (() => {}) as any;
|
||||||
(global as any).scrollTo = () => {};
|
(global as any).scrollTo = () => {};
|
||||||
(global as any).prompt = () => {};
|
(global as any).matchMedia = () => ({ matches: false });
|
||||||
(global as any).matchMedia = (media: string) => ({ matches: false, media });
|
|
||||||
|
|
|
@ -3,8 +3,8 @@ 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:20.2-alpine
|
image: node:20.7-alpine
|
||||||
command: /bin/sh -c "cd /home/shlink/www && npm install --force && npm run start"
|
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/home/shlink/www
|
- ./:/home/shlink/www
|
||||||
ports:
|
ports:
|
||||||
|
|
2
indocker
2
indocker
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
# Run docker container if it's not up yet
|
# Run docker container if it's not up yet
|
||||||
if ! [[ $(docker ps | grep shlink_web_client_node) ]]; then
|
if ! [[ $(docker ps | grep shlink_web_client_node) ]]; then
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker exec -it shlink_web_client_node /bin/sh -c "cd /home/shlink/www && $*"
|
docker exec -it shlink_web_client_node /bin/sh -c "cd /home/shlink/www && $*"
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
export const manifest = {
|
import type { ManifestOptions } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
|
export const manifest: Partial<ManifestOptions> = {
|
||||||
short_name: 'Shlink',
|
short_name: 'Shlink',
|
||||||
name: 'Shlink',
|
name: 'Shlink',
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
|
|
11030
package-lock.json
generated
11030
package-lock.json
generated
File diff suppressed because it is too large
Load diff
111
package.json
111
package.json
|
@ -5,6 +5,7 @@
|
||||||
"homepage": "",
|
"homepage": "",
|
||||||
"repository": "https://github.com/shlinkio/shlink-web-client",
|
"repository": "https://github.com/shlinkio/shlink-web-client",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "npm run lint:css && npm run lint:js",
|
"lint": "npm run lint:css && npm run lint:js",
|
||||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||||
|
@ -23,80 +24,62 @@
|
||||||
"test:verbose": "npm run test -- --verbose"
|
"test:verbose": "npm run test -- --verbose"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.3.0",
|
"@fortawesome/fontawesome-free": "^6.5.1",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.3.0",
|
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.3.0",
|
"@fortawesome/free-brands-svg-icons": "^6.5.1",
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.3.0",
|
"@fortawesome/free-regular-svg-icons": "^6.5.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.3.0",
|
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
"@json2csv/plainjs": "^6.1.2",
|
"@json2csv/plainjs": "^7.0.5",
|
||||||
"@reduxjs/toolkit": "^1.9.1",
|
"@reduxjs/toolkit": "^2.1.0",
|
||||||
"bootstrap": "^5.2.3",
|
"@shlinkio/data-manipulation": "^1.0.3",
|
||||||
|
"@shlinkio/shlink-frontend-kit": "^0.4.2",
|
||||||
|
"@shlinkio/shlink-js-sdk": "^0.2.2",
|
||||||
|
"@shlinkio/shlink-web-component": "^0.5.0",
|
||||||
|
"bootstrap": "5.2.3",
|
||||||
"bottlejs": "^2.0.1",
|
"bottlejs": "^2.0.1",
|
||||||
"bowser": "^2.11.0",
|
"clsx": "^2.1.0",
|
||||||
"chart.js": "^4.1.1",
|
"compare-versions": "^6.1.0",
|
||||||
"classnames": "^2.3.2",
|
|
||||||
"compare-versions": "^5.0.3",
|
|
||||||
"csvtojson": "^2.0.10",
|
"csvtojson": "^2.0.10",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^3.3.1",
|
||||||
"event-source-polyfill": "^1.0.31",
|
|
||||||
"history": "^5.3.0",
|
|
||||||
"leaflet": "^1.9.3",
|
|
||||||
"qs": "^6.11.0",
|
|
||||||
"ramda": "^0.27.2",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-chartjs-2": "^5.1.0",
|
|
||||||
"react-colorful": "^5.6.1",
|
|
||||||
"react-copy-to-clipboard": "^5.1.0",
|
|
||||||
"react-datepicker": "^4.8.0",
|
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-external-link": "^2.2.0",
|
"react-external-link": "^2.2.0",
|
||||||
"react-leaflet": "^4.2.0",
|
"react-redux": "^9.1.0",
|
||||||
"react-redux": "^8.0.5",
|
"react-router-dom": "^6.21.3",
|
||||||
"react-router-dom": "^6.6.1",
|
"reactstrap": "^9.2.2",
|
||||||
"react-swipeable": "^7.0.0",
|
|
||||||
"react-tag-autocomplete": "^6.3.0",
|
|
||||||
"reactstrap": "^9.1.5",
|
|
||||||
"redux-localstorage-simple": "^2.5.1",
|
"redux-localstorage-simple": "^2.5.1",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^9.0.1",
|
||||||
"workbox-core": "^6.5.4",
|
"workbox-core": "^7.0.0",
|
||||||
"workbox-expiration": "^6.5.4",
|
"workbox-expiration": "^7.0.0",
|
||||||
"workbox-precaching": "^6.5.4",
|
"workbox-precaching": "^7.0.0",
|
||||||
"workbox-routing": "^6.5.4",
|
"workbox-routing": "^7.0.0",
|
||||||
"workbox-strategies": "^6.5.4"
|
"workbox-strategies": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@shlinkio/eslint-config-js-coding-standard": "~2.1.0",
|
"@shlinkio/eslint-config-js-coding-standard": "~2.3.0",
|
||||||
"@shlinkio/stylelint-config-css-coding-standard": "~1.0.1",
|
"@shlinkio/stylelint-config-css-coding-standard": "~1.1.1",
|
||||||
"@testing-library/jest-dom": "^5.16.5",
|
"@testing-library/jest-dom": "^6.3.0",
|
||||||
"@testing-library/react": "^14.0.0",
|
"@testing-library/react": "^14.1.2",
|
||||||
"@testing-library/user-event": "^14.4.3",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@total-typescript/shoehorn": "^0.1.0",
|
"@total-typescript/shoehorn": "^0.1.1",
|
||||||
"@types/json2csv": "^5.0.3",
|
"@types/react": "^18.2.48",
|
||||||
"@types/leaflet": "^1.9.0",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@types/qs": "^6.9.7",
|
"@types/uuid": "^9.0.8",
|
||||||
"@types/ramda": "^0.28.15",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"@types/react": "^18.0.26",
|
"@vitest/coverage-v8": "^1.2.2",
|
||||||
"@types/react-color": "^3.0.6",
|
|
||||||
"@types/react-copy-to-clipboard": "^5.0.4",
|
|
||||||
"@types/react-datepicker": "^4.8.0",
|
|
||||||
"@types/react-dom": "^18.0.10",
|
|
||||||
"@types/react-tag-autocomplete": "^6.3.0",
|
|
||||||
"@types/uuid": "^8.3.4",
|
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
|
||||||
"@vitest/coverage-v8": "^0.32.0",
|
|
||||||
"adm-zip": "^0.5.10",
|
"adm-zip": "^0.5.10",
|
||||||
"chalk": "^5.2.0",
|
"axe-core": "^4.8.3",
|
||||||
"eslint": "^8.30.0",
|
"chalk": "^5.3.0",
|
||||||
"jsdom": "^22.0.0",
|
"eslint": "^8.56.0",
|
||||||
"resize-observer-polyfill": "^1.5.1",
|
"history": "^5.3.0",
|
||||||
"sass": "^1.57.1",
|
"jsdom": "^24.0.0",
|
||||||
"stylelint": "^15.10.1",
|
"sass": "^1.70.0",
|
||||||
"typescript": "^5.0.2",
|
"stylelint": "^15.11.0",
|
||||||
"vite": "^4.3.9",
|
"typescript": "^5.3.3",
|
||||||
"vite-plugin-pwa": "^0.14.4",
|
"vite": "^5.0.12",
|
||||||
"vitest": "^0.32.0",
|
"vite-plugin-pwa": "^0.17.5",
|
||||||
"vitest-canvas-mock": "^0.2.2"
|
"vitest": "^1.2.2"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
">0.2%",
|
">0.2%",
|
||||||
|
|
10
shlink-web-client.d.ts
vendored
10
shlink-web-client.d.ts
vendored
|
@ -1,13 +1,3 @@
|
||||||
// eslint-disable-next-line max-classes-per-file
|
|
||||||
declare module 'event-source-polyfill' {
|
|
||||||
declare class EventSourcePolyfill {
|
|
||||||
public onmessage?: ({ data }: { data: string }) => void;
|
|
||||||
public onerror?: ({ status }: { status: number }) => void;
|
|
||||||
public close: () => void;
|
|
||||||
public constructor(hubUrl: URL, options?: any);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '@json2csv/plainjs' {
|
declare module '@json2csv/plainjs' {
|
||||||
export class Parser {
|
export class Parser {
|
||||||
parse: <T>(data: T[]) => string;
|
parse: <T>(data: T[]) => string;
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
import type { ProblemDetailsError } from './types/errors';
|
|
||||||
import { isInvalidArgumentError } from './utils';
|
|
||||||
|
|
||||||
export interface ShlinkApiErrorProps {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
fallbackMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ShlinkApiError = ({ errorData, fallbackMessage }: ShlinkApiErrorProps) => (
|
|
||||||
<>
|
|
||||||
{errorData?.detail ?? fallbackMessage}
|
|
||||||
{isInvalidArgumentError(errorData) && (
|
|
||||||
<p className="mb-0">
|
|
||||||
Invalid elements: [{errorData.invalidElements.join(', ')}]
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
|
@ -1,149 +0,0 @@
|
||||||
import { isEmpty, isNil, reject } from 'ramda';
|
|
||||||
import type { HttpClient } from '../../common/services/HttpClient';
|
|
||||||
import type { ShortUrl, ShortUrlData } from '../../short-urls/data';
|
|
||||||
import { orderToString } from '../../utils/helpers/ordering';
|
|
||||||
import { stringifyQuery } from '../../utils/helpers/query';
|
|
||||||
import type { OptionalString } from '../../utils/utils';
|
|
||||||
import type {
|
|
||||||
ShlinkDomainRedirects,
|
|
||||||
ShlinkDomainsResponse,
|
|
||||||
ShlinkEditDomainRedirects,
|
|
||||||
ShlinkHealth,
|
|
||||||
ShlinkMercureInfo,
|
|
||||||
ShlinkShortUrlData,
|
|
||||||
ShlinkShortUrlsListNormalizedParams,
|
|
||||||
ShlinkShortUrlsListParams,
|
|
||||||
ShlinkShortUrlsResponse,
|
|
||||||
ShlinkTags,
|
|
||||||
ShlinkTagsResponse,
|
|
||||||
ShlinkTagsStatsResponse,
|
|
||||||
ShlinkVisits,
|
|
||||||
ShlinkVisitsOverview,
|
|
||||||
ShlinkVisitsParams,
|
|
||||||
} from '../types';
|
|
||||||
import { isRegularNotFound, parseApiError } from '../utils';
|
|
||||||
|
|
||||||
const buildShlinkBaseUrl = (url: string, version: 2 | 3) => `${url}/rest/v${version}`;
|
|
||||||
const rejectNilProps = reject(isNil);
|
|
||||||
const normalizeListParams = (
|
|
||||||
{ orderBy = {}, excludeMaxVisitsReached, excludePastValidUntil, ...rest }: ShlinkShortUrlsListParams,
|
|
||||||
): ShlinkShortUrlsListNormalizedParams => ({
|
|
||||||
...rest,
|
|
||||||
excludeMaxVisitsReached: excludeMaxVisitsReached === true ? 'true' : undefined,
|
|
||||||
excludePastValidUntil: excludePastValidUntil === true ? 'true' : undefined,
|
|
||||||
orderBy: orderToString(orderBy),
|
|
||||||
});
|
|
||||||
|
|
||||||
export class ShlinkApiClient {
|
|
||||||
private apiVersion: 2 | 3;
|
|
||||||
|
|
||||||
public constructor(
|
|
||||||
private readonly httpClient: HttpClient,
|
|
||||||
private readonly baseUrl: string,
|
|
||||||
private readonly apiKey: string,
|
|
||||||
) {
|
|
||||||
this.apiVersion = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
|
|
||||||
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeListParams(params))
|
|
||||||
.then(({ shortUrls }) => shortUrls);
|
|
||||||
|
|
||||||
public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => {
|
|
||||||
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options as any);
|
|
||||||
return this.performRequest<ShortUrl>('/short-urls', 'POST', {}, filteredOptions);
|
|
||||||
};
|
|
||||||
|
|
||||||
public readonly getShortUrlVisits = async (shortCode: string, query?: ShlinkVisitsParams): Promise<ShlinkVisits> =>
|
|
||||||
this.performRequest<{ visits: ShlinkVisits }>(`/short-urls/${shortCode}/visits`, 'GET', query)
|
|
||||||
.then(({ visits }) => visits);
|
|
||||||
|
|
||||||
public readonly getTagVisits = async (tag: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
|
||||||
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query).then(({ visits }) => visits);
|
|
||||||
|
|
||||||
public readonly getDomainVisits = async (domain: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
|
||||||
this.performRequest<{ visits: ShlinkVisits }>(`/domains/${domain}/visits`, 'GET', query).then(({ visits }) => visits);
|
|
||||||
|
|
||||||
public readonly getOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
|
||||||
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query).then(({ visits }) => visits);
|
|
||||||
|
|
||||||
public readonly getNonOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
|
||||||
this.performRequest<{ visits: ShlinkVisits }>('/visits/non-orphan', 'GET', query).then(({ visits }) => visits);
|
|
||||||
|
|
||||||
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
|
|
||||||
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits').then(({ visits }) => visits);
|
|
||||||
|
|
||||||
public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise<ShortUrl> =>
|
|
||||||
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain });
|
|
||||||
|
|
||||||
public readonly deleteShortUrl = async (shortCode: string, domain?: OptionalString): Promise<void> =>
|
|
||||||
this.performEmptyRequest(`/short-urls/${shortCode}`, 'DELETE', { domain });
|
|
||||||
|
|
||||||
public readonly updateShortUrl = async (
|
|
||||||
shortCode: string,
|
|
||||||
domain: OptionalString,
|
|
||||||
edit: ShlinkShortUrlData,
|
|
||||||
): Promise<ShortUrl> =>
|
|
||||||
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, edit);
|
|
||||||
|
|
||||||
public readonly listTags = async (): Promise<ShlinkTags> =>
|
|
||||||
this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' })
|
|
||||||
.then(({ tags }) => tags)
|
|
||||||
.then(({ data, stats }) => ({ tags: data, stats }));
|
|
||||||
|
|
||||||
public readonly tagsStats = async (): Promise<ShlinkTags> =>
|
|
||||||
this.performRequest<{ tags: ShlinkTagsStatsResponse }>('/tags/stats', 'GET')
|
|
||||||
.then(({ tags }) => tags)
|
|
||||||
.then(({ data }) => ({ tags: data.map(({ tag }) => tag), stats: data }));
|
|
||||||
|
|
||||||
public readonly deleteTags = async (tags: string[]): Promise<{ tags: string[] }> =>
|
|
||||||
this.performEmptyRequest('/tags', 'DELETE', { tags }).then(() => ({ tags }));
|
|
||||||
|
|
||||||
public readonly editTag = async (oldName: string, newName: string): Promise<{ oldName: string; newName: string }> =>
|
|
||||||
this.performEmptyRequest('/tags', 'PUT', {}, { oldName, newName }).then(() => ({ oldName, newName }));
|
|
||||||
|
|
||||||
public readonly health = async (): Promise<ShlinkHealth> => this.performRequest<ShlinkHealth>('/health', 'GET');
|
|
||||||
|
|
||||||
public readonly mercureInfo = async (): Promise<ShlinkMercureInfo> =>
|
|
||||||
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET');
|
|
||||||
|
|
||||||
public readonly listDomains = async (): Promise<ShlinkDomainsResponse> =>
|
|
||||||
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains').then(({ domains }) => domains);
|
|
||||||
|
|
||||||
public readonly editDomainRedirects = async (
|
|
||||||
domainRedirects: ShlinkEditDomainRedirects,
|
|
||||||
): Promise<ShlinkDomainRedirects> =>
|
|
||||||
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects);
|
|
||||||
|
|
||||||
private readonly performRequest = async <T>(url: string, method = 'GET', query = {}, body?: object): Promise<T> =>
|
|
||||||
this.httpClient.fetchJson<T>(...this.toFetchParams(url, method, query, body)).catch(
|
|
||||||
this.handleFetchError(() => this.httpClient.fetchJson<T>(...this.toFetchParams(url, method, query, body))),
|
|
||||||
);
|
|
||||||
|
|
||||||
private readonly performEmptyRequest = async (url: string, method = 'GET', query = {}, body?: object): Promise<void> =>
|
|
||||||
this.httpClient.fetchEmpty(...this.toFetchParams(url, method, query, body)).catch(
|
|
||||||
this.handleFetchError(() => this.httpClient.fetchEmpty(...this.toFetchParams(url, method, query, body))),
|
|
||||||
);
|
|
||||||
|
|
||||||
private readonly toFetchParams = (url: string, method: string, query = {}, body?: object): [string, RequestInit] => {
|
|
||||||
const normalizedQuery = stringifyQuery(rejectNilProps(query));
|
|
||||||
const stringifiedQuery = isEmpty(normalizedQuery) ? '' : `?${normalizedQuery}`;
|
|
||||||
|
|
||||||
return [`${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}${stringifiedQuery}`, {
|
|
||||||
method,
|
|
||||||
body: body && JSON.stringify(body),
|
|
||||||
headers: { 'X-Api-Key': this.apiKey },
|
|
||||||
}];
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly handleFetchError = (retryFetch: Function) => (e: unknown) => {
|
|
||||||
if (!isRegularNotFound(parseApiError(e))) {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we capture a not found error, let's assume this Shlink version does not support API v3, so we decrease to
|
|
||||||
// v2 and retry
|
|
||||||
this.apiVersion = 2;
|
|
||||||
return retryFetch();
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,8 +1,8 @@
|
||||||
import type { HttpClient } from '../../common/services/HttpClient';
|
import type { HttpClient } from '@shlinkio/shlink-js-sdk';
|
||||||
|
import { ShlinkApiClient } from '@shlinkio/shlink-js-sdk';
|
||||||
import type { GetState } from '../../container/types';
|
import type { GetState } from '../../container/types';
|
||||||
import type { ServerWithId } from '../../servers/data';
|
import type { ServerWithId } from '../../servers/data';
|
||||||
import { hasServerData } from '../../servers/data';
|
import { hasServerData } from '../../servers/data';
|
||||||
import { ShlinkApiClient } from './ShlinkApiClient';
|
|
||||||
|
|
||||||
const apiClients: Record<string, ShlinkApiClient> = {};
|
const apiClients: Record<string, ShlinkApiClient> = {};
|
||||||
|
|
||||||
|
@ -18,16 +18,15 @@ const getSelectedServerFromState = (getState: GetState): ServerWithId => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => {
|
export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => {
|
||||||
const { url, apiKey } = isGetState(getStateOrSelectedServer)
|
const { url: baseUrl, apiKey } = isGetState(getStateOrSelectedServer)
|
||||||
? getSelectedServerFromState(getStateOrSelectedServer)
|
? getSelectedServerFromState(getStateOrSelectedServer)
|
||||||
: getStateOrSelectedServer;
|
: getStateOrSelectedServer;
|
||||||
const clientKey = `${url}_${apiKey}`;
|
const serverKey = `${apiKey}_${baseUrl}`;
|
||||||
|
|
||||||
if (!apiClients[clientKey]) {
|
const apiClient = apiClients[serverKey] ?? new ShlinkApiClient(httpClient, { apiKey, baseUrl });
|
||||||
apiClients[clientKey] = new ShlinkApiClient(httpClient, url, apiKey);
|
apiClients[serverKey] = apiClient;
|
||||||
}
|
|
||||||
|
|
||||||
return apiClients[clientKey];
|
return apiClient;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ShlinkApiClientBuilder = ReturnType<typeof buildShlinkApiClient>;
|
export type ShlinkApiClientBuilder = ReturnType<typeof buildShlinkApiClient>;
|
||||||
|
|
|
@ -1,54 +0,0 @@
|
||||||
export enum ErrorTypeV2 {
|
|
||||||
INVALID_ARGUMENT = 'INVALID_ARGUMENT',
|
|
||||||
INVALID_SHORT_URL_DELETION = 'INVALID_SHORT_URL_DELETION',
|
|
||||||
DOMAIN_NOT_FOUND = 'DOMAIN_NOT_FOUND',
|
|
||||||
FORBIDDEN_OPERATION = 'FORBIDDEN_OPERATION',
|
|
||||||
INVALID_URL = 'INVALID_URL',
|
|
||||||
INVALID_SLUG = 'INVALID_SLUG',
|
|
||||||
INVALID_SHORTCODE = 'INVALID_SHORTCODE',
|
|
||||||
TAG_CONFLICT = 'TAG_CONFLICT',
|
|
||||||
TAG_NOT_FOUND = 'TAG_NOT_FOUND',
|
|
||||||
MERCURE_NOT_CONFIGURED = 'MERCURE_NOT_CONFIGURED',
|
|
||||||
INVALID_AUTHORIZATION = 'INVALID_AUTHORIZATION',
|
|
||||||
INVALID_API_KEY = 'INVALID_API_KEY',
|
|
||||||
NOT_FOUND = 'NOT_FOUND',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ErrorTypeV3 {
|
|
||||||
INVALID_ARGUMENT = 'https://shlink.io/api/error/invalid-data',
|
|
||||||
INVALID_SHORT_URL_DELETION = 'https://shlink.io/api/error/invalid-short-url-deletion',
|
|
||||||
DOMAIN_NOT_FOUND = 'https://shlink.io/api/error/domain-not-found',
|
|
||||||
FORBIDDEN_OPERATION = 'https://shlink.io/api/error/forbidden-tag-operation',
|
|
||||||
INVALID_URL = 'https://shlink.io/api/error/invalid-url',
|
|
||||||
INVALID_SLUG = 'https://shlink.io/api/error/non-unique-slug',
|
|
||||||
INVALID_SHORTCODE = 'https://shlink.io/api/error/short-url-not-found',
|
|
||||||
TAG_CONFLICT = 'https://shlink.io/api/error/tag-conflict',
|
|
||||||
TAG_NOT_FOUND = 'https://shlink.io/api/error/tag-not-found',
|
|
||||||
MERCURE_NOT_CONFIGURED = 'https://shlink.io/api/error/mercure-not-configured',
|
|
||||||
INVALID_AUTHORIZATION = 'https://shlink.io/api/error/missing-authentication',
|
|
||||||
INVALID_API_KEY = 'https://shlink.io/api/error/invalid-api-key',
|
|
||||||
NOT_FOUND = 'https://shlink.io/api/error/not-found',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProblemDetailsError {
|
|
||||||
type: string;
|
|
||||||
detail: string;
|
|
||||||
title: string;
|
|
||||||
status: number;
|
|
||||||
[extraProps: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InvalidArgumentError extends ProblemDetailsError {
|
|
||||||
type: ErrorTypeV2.INVALID_ARGUMENT | ErrorTypeV3.INVALID_ARGUMENT;
|
|
||||||
invalidElements: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InvalidShortUrlDeletion extends ProblemDetailsError {
|
|
||||||
type: 'INVALID_SHORTCODE_DELETION' | ErrorTypeV2.INVALID_SHORT_URL_DELETION | ErrorTypeV3.INVALID_SHORT_URL_DELETION;
|
|
||||||
threshold: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegularNotFound extends ProblemDetailsError {
|
|
||||||
type: ErrorTypeV2.NOT_FOUND | ErrorTypeV3.NOT_FOUND;
|
|
||||||
status: 404;
|
|
||||||
}
|
|
|
@ -1,133 +0,0 @@
|
||||||
import type { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
|
|
||||||
import type { Order } from '../../utils/helpers/ordering';
|
|
||||||
import type { OptionalString } from '../../utils/utils';
|
|
||||||
import type { Visit } from '../../visits/types';
|
|
||||||
|
|
||||||
export interface ShlinkShortUrlsResponse {
|
|
||||||
data: ShortUrl[];
|
|
||||||
pagination: ShlinkPaginator;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkMercureInfo {
|
|
||||||
token: string;
|
|
||||||
mercureHubUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkHealth {
|
|
||||||
status: 'pass' | 'fail';
|
|
||||||
version: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkTagsStats {
|
|
||||||
tag: string;
|
|
||||||
shortUrlsCount: number;
|
|
||||||
visitsSummary?: ShlinkVisitsSummary; // Optional only before Shlink 3.5.0
|
|
||||||
|
|
||||||
/** @deprecated */
|
|
||||||
visitsCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkTags {
|
|
||||||
tags: string[];
|
|
||||||
stats: ShlinkTagsStats[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkTagsResponse {
|
|
||||||
data: string[];
|
|
||||||
/** @deprecated Present only when withStats=true is provided, which is deprecated */
|
|
||||||
stats: ShlinkTagsStats[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkTagsStatsResponse {
|
|
||||||
data: ShlinkTagsStats[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkPaginator {
|
|
||||||
currentPage: number;
|
|
||||||
pagesCount: number;
|
|
||||||
totalItems: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkVisitsSummary {
|
|
||||||
total: number;
|
|
||||||
nonBots: number;
|
|
||||||
bots: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkVisits {
|
|
||||||
data: Visit[];
|
|
||||||
pagination: ShlinkPaginator;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkVisitsOverview {
|
|
||||||
nonOrphanVisits?: ShlinkVisitsSummary; // Optional only before Shlink 3.5.0
|
|
||||||
orphanVisits?: ShlinkVisitsSummary; // Optional only before Shlink 3.5.0
|
|
||||||
|
|
||||||
/** @deprecated */
|
|
||||||
visitsCount: number;
|
|
||||||
/** @deprecated */
|
|
||||||
orphanVisitsCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkVisitsParams {
|
|
||||||
domain?: OptionalString;
|
|
||||||
page?: number;
|
|
||||||
itemsPerPage?: number;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
excludeBots?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkShortUrlData extends ShortUrlMeta {
|
|
||||||
longUrl?: string;
|
|
||||||
title?: string;
|
|
||||||
validateUrl?: boolean;
|
|
||||||
tags?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkDomainRedirects {
|
|
||||||
baseUrlRedirect: string | null;
|
|
||||||
regular404Redirect: string | null;
|
|
||||||
invalidShortUrlRedirect: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkEditDomainRedirects extends Partial<ShlinkDomainRedirects> {
|
|
||||||
domain: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkDomain {
|
|
||||||
domain: string;
|
|
||||||
isDefault: boolean;
|
|
||||||
redirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.8
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkDomainsResponse {
|
|
||||||
data: ShlinkDomain[];
|
|
||||||
defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TagsFilteringMode = 'all' | 'any';
|
|
||||||
|
|
||||||
type ShlinkShortUrlsOrderableFields = 'dateCreated' | 'shortCode' | 'longUrl' | 'title' | 'visits' | 'nonBotVisits';
|
|
||||||
|
|
||||||
export type ShlinkShortUrlsOrder = Order<ShlinkShortUrlsOrderableFields>;
|
|
||||||
|
|
||||||
export interface ShlinkShortUrlsListParams {
|
|
||||||
page?: string;
|
|
||||||
itemsPerPage?: number;
|
|
||||||
searchTerm?: string;
|
|
||||||
tags?: string[];
|
|
||||||
tagsMode?: TagsFilteringMode;
|
|
||||||
orderBy?: ShlinkShortUrlsOrder;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
excludeMaxVisitsReached?: boolean;
|
|
||||||
excludePastValidUntil?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkShortUrlsListNormalizedParams extends
|
|
||||||
Omit<ShlinkShortUrlsListParams, 'orderBy' | 'excludeMaxVisitsReached' | 'excludePastValidUntil'> {
|
|
||||||
orderBy?: string;
|
|
||||||
excludeMaxVisitsReached?: 'true';
|
|
||||||
excludePastValidUntil?: 'true';
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
import type {
|
|
||||||
InvalidArgumentError,
|
|
||||||
InvalidShortUrlDeletion,
|
|
||||||
ProblemDetailsError,
|
|
||||||
RegularNotFound } from '../types/errors';
|
|
||||||
import {
|
|
||||||
ErrorTypeV2,
|
|
||||||
ErrorTypeV3,
|
|
||||||
} from '../types/errors';
|
|
||||||
|
|
||||||
const isProblemDetails = (e: unknown): e is ProblemDetailsError =>
|
|
||||||
!!e && typeof e === 'object' && ['type', 'detail', 'title', 'status'].every((prop) => prop in e);
|
|
||||||
|
|
||||||
export const parseApiError = (e: unknown): ProblemDetailsError | undefined => (isProblemDetails(e) ? e : undefined);
|
|
||||||
|
|
||||||
export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError =>
|
|
||||||
error?.type === ErrorTypeV2.INVALID_ARGUMENT || error?.type === ErrorTypeV3.INVALID_ARGUMENT;
|
|
||||||
|
|
||||||
export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion =>
|
|
||||||
error?.type === 'INVALID_SHORTCODE_DELETION'
|
|
||||||
|| error?.type === ErrorTypeV2.INVALID_SHORT_URL_DELETION
|
|
||||||
|| error?.type === ErrorTypeV3.INVALID_SHORT_URL_DELETION;
|
|
||||||
|
|
||||||
export const isRegularNotFound = (error?: ProblemDetailsError): error is RegularNotFound =>
|
|
||||||
(error?.type === ErrorTypeV2.NOT_FOUND || error?.type === ErrorTypeV3.NOT_FOUND) && error?.status === 404;
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../utils/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
.app-container {
|
.app-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
|
@ -1,58 +1,79 @@
|
||||||
import classNames from 'classnames';
|
import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
||||||
import { NotFound } from '../common/NotFound';
|
import { NotFound } from '../common/NotFound';
|
||||||
|
import type { FCWithDeps } from '../container/utils';
|
||||||
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import type { ServersMap } from '../servers/data';
|
import type { ServersMap } from '../servers/data';
|
||||||
import type { Settings } from '../settings/reducers/settings';
|
import type { AppSettings } from '../settings/reducers/settings';
|
||||||
import { forceUpdate } from '../utils/helpers/sw';
|
import { forceUpdate } from '../utils/helpers/sw';
|
||||||
import { changeThemeInMarkup } from '../utils/theme';
|
|
||||||
import './App.scss';
|
import './App.scss';
|
||||||
|
|
||||||
interface AppProps {
|
type AppProps = {
|
||||||
fetchServers: () => void;
|
fetchServers: () => void;
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
settings: Settings;
|
settings: AppSettings;
|
||||||
resetAppUpdate: () => void;
|
resetAppUpdate: () => void;
|
||||||
appUpdated: boolean;
|
appUpdated: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
type AppDeps = {
|
||||||
|
MainHeader: FC;
|
||||||
|
Home: FC;
|
||||||
|
ShlinkWebComponentContainer: FC;
|
||||||
|
CreateServer: FC;
|
||||||
|
EditServer: FC;
|
||||||
|
Settings: FC;
|
||||||
|
ManageServers: FC;
|
||||||
|
ShlinkVersionsContainer: FC;
|
||||||
|
};
|
||||||
|
|
||||||
|
const App: FCWithDeps<AppProps, AppDeps> = (
|
||||||
|
{ fetchServers, servers, settings, appUpdated, resetAppUpdate },
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
MainHeader,
|
||||||
|
Home,
|
||||||
|
ShlinkWebComponentContainer,
|
||||||
|
CreateServer,
|
||||||
|
EditServer,
|
||||||
|
Settings,
|
||||||
|
ManageServers,
|
||||||
|
ShlinkVersionsContainer,
|
||||||
|
} = useDependencies(App);
|
||||||
|
|
||||||
export const App = (
|
|
||||||
MainHeader: FC,
|
|
||||||
Home: FC,
|
|
||||||
MenuLayout: FC,
|
|
||||||
CreateServer: FC,
|
|
||||||
EditServer: FC,
|
|
||||||
SettingsComp: FC,
|
|
||||||
ManageServers: FC,
|
|
||||||
ShlinkVersionsContainer: FC,
|
|
||||||
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const initialServers = useRef(servers);
|
||||||
const isHome = location.pathname === '/';
|
const isHome = location.pathname === '/';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// On first load, try to fetch the remote servers if the list is empty
|
// Try to fetch the remote servers if the list is empty at first
|
||||||
if (Object.keys(servers).length === 0) {
|
// We use a ref because we don't care if the servers list becomes empty later
|
||||||
|
if (Object.keys(initialServers.current).length === 0) {
|
||||||
fetchServers();
|
fetchServers();
|
||||||
}
|
}
|
||||||
|
}, [fetchServers]);
|
||||||
|
|
||||||
changeThemeInMarkup(settings.ui?.theme ?? 'light');
|
useEffect(() => {
|
||||||
}, []);
|
changeThemeInMarkup(settings.ui?.theme ?? getSystemPreferredTheme());
|
||||||
|
}, [settings.ui?.theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container-fluid app-container">
|
<div className="container-fluid app-container">
|
||||||
<MainHeader />
|
<MainHeader />
|
||||||
|
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<div className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
|
<div className={clsx('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Home />} />
|
<Route index element={<Home />} />
|
||||||
<Route path="/settings/*" element={<SettingsComp />} />
|
<Route path="/settings/*" element={<Settings />} />
|
||||||
<Route path="/manage-servers" element={<ManageServers />} />
|
<Route path="/manage-servers" element={<ManageServers />} />
|
||||||
<Route path="/server/create" element={<CreateServer />} />
|
<Route path="/server/create" element={<CreateServer />} />
|
||||||
<Route path="/server/:serverId/edit" element={<EditServer />} />
|
<Route path="/server/:serverId/edit" element={<EditServer />} />
|
||||||
<Route path="/server/:serverId/*" element={<MenuLayout />} />
|
<Route path="/server/:serverId/*" element={<ShlinkWebComponentContainer />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
@ -66,3 +87,14 @@ export const App = (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const AppFactory = componentFactory(App, [
|
||||||
|
'MainHeader',
|
||||||
|
'Home',
|
||||||
|
'ShlinkWebComponentContainer',
|
||||||
|
'CreateServer',
|
||||||
|
'EditServer',
|
||||||
|
'Settings',
|
||||||
|
'ManageServers',
|
||||||
|
'ShlinkVersionsContainer',
|
||||||
|
]);
|
||||||
|
|
|
@ -1,22 +1,11 @@
|
||||||
import type Bottle from 'bottlejs';
|
import type Bottle from 'bottlejs';
|
||||||
import type { ConnectDecorator } from '../../container/types';
|
import type { ConnectDecorator } from '../../container/types';
|
||||||
import { App } from '../App';
|
import { AppFactory } from '../App';
|
||||||
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
|
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
|
||||||
|
|
||||||
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory(
|
bottle.factory('App', AppFactory);
|
||||||
'App',
|
|
||||||
App,
|
|
||||||
'MainHeader',
|
|
||||||
'Home',
|
|
||||||
'MenuLayout',
|
|
||||||
'CreateServer',
|
|
||||||
'EditServer',
|
|
||||||
'Settings',
|
|
||||||
'ManageServers',
|
|
||||||
'ShlinkVersionsContainer',
|
|
||||||
);
|
|
||||||
bottle.decorator('App', connect(['servers', 'settings', 'appUpdated'], ['fetchServers', 'resetAppUpdate']));
|
bottle.decorator('App', connect(['servers', 'settings', 'appUpdated'], ['fetchServers', 'resetAppUpdate']));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../utils/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
@import '../utils/mixins/horizontal-align';
|
@import '../utils/mixins/horizontal-align';
|
||||||
|
|
||||||
.app-update-banner.app-update-banner {
|
.app-update-banner.app-update-banner {
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import type { FC, MouseEventHandler } from 'react';
|
import { SimpleCard, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import type { MouseEventHandler } from 'react';
|
||||||
|
import { forwardRef, useCallback } from 'react';
|
||||||
import { Alert, Button } from 'reactstrap';
|
import { Alert, Button } from 'reactstrap';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
|
||||||
import './AppUpdateBanner.scss';
|
import './AppUpdateBanner.scss';
|
||||||
|
|
||||||
interface AppUpdateBannerProps {
|
interface AppUpdateBannerProps {
|
||||||
|
@ -12,15 +12,15 @@ interface AppUpdateBannerProps {
|
||||||
forceUpdate: Function;
|
forceUpdate: Function;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forceUpdate }) => {
|
export const AppUpdateBanner = forwardRef<HTMLElement, AppUpdateBannerProps>(({ isOpen, toggle, forceUpdate }, ref) => {
|
||||||
const [isUpdating,, setUpdating] = useToggle();
|
const [isUpdating,, setUpdating] = useToggle();
|
||||||
const update = () => {
|
const update = useCallback(() => {
|
||||||
setUpdating();
|
setUpdating();
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
};
|
}, [forceUpdate, setUpdating]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert className="app-update-banner" isOpen={isOpen} toggle={toggle} tag={SimpleCard} color="secondary">
|
<Alert className="app-update-banner" isOpen={isOpen} toggle={toggle} tag={SimpleCard} color="secondary" innerRef={ref}>
|
||||||
<h4 className="mb-4">This app has just been updated!</h4>
|
<h4 className="mb-4">This app has just been updated!</h4>
|
||||||
<p className="mb-0">
|
<p className="mb-0">
|
||||||
Restart it to enjoy the new features.
|
Restart it to enjoy the new features.
|
||||||
|
@ -31,4 +31,4 @@ export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forc
|
||||||
</p>
|
</p>
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
@ -1,81 +0,0 @@
|
||||||
@import '../utils/base';
|
|
||||||
@import '../utils/mixins/vertical-align';
|
|
||||||
|
|
||||||
.aside-menu {
|
|
||||||
width: $asideMenuWidth;
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
box-shadow: rgb(0 0 0 / .05) 0 8px 15px;
|
|
||||||
position: fixed !important;
|
|
||||||
padding-top: 13px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
top: $headerHeight;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
display: block;
|
|
||||||
z-index: 1010;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
|
||||||
padding: 30px 15px 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: $smMax) {
|
|
||||||
transition: left 300ms;
|
|
||||||
top: $headerHeight - 3px;
|
|
||||||
box-shadow: -10px 0 50px 11px rgb(0 0 0 / .55);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.aside-menu--hidden {
|
|
||||||
@media (max-width: $smMax) {
|
|
||||||
left: -($asideMenuWidth + 35px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.aside-menu__nav {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aside-menu__item {
|
|
||||||
padding: 10px 20px;
|
|
||||||
margin: 0 -15px;
|
|
||||||
text-decoration: none !important;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
@media (max-width: $smMax) {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.aside-menu__item:hover {
|
|
||||||
background-color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aside-menu__item--selected,
|
|
||||||
.aside-menu__item--selected:hover {
|
|
||||||
color: #ffffff;
|
|
||||||
background-color: var(--brand-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aside-menu__item--divider {
|
|
||||||
border-bottom: 1px solid #eeeeee;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aside-menu__item--danger {
|
|
||||||
color: $dangerColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aside-menu__item--push {
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aside-menu__item--danger:hover {
|
|
||||||
color: #ffffff;
|
|
||||||
background-color: $dangerColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aside-menu__item-text {
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
|
@ -1,90 +0,0 @@
|
||||||
import {
|
|
||||||
faGlobe as domainsIcon,
|
|
||||||
faHome as overviewIcon,
|
|
||||||
faLink as createIcon,
|
|
||||||
faList as listIcon,
|
|
||||||
faPen as editIcon,
|
|
||||||
faTags as tagsIcon,
|
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import type { FC } from 'react';
|
|
||||||
import type { NavLinkProps } from 'react-router-dom';
|
|
||||||
import { NavLink, useLocation } from 'react-router-dom';
|
|
||||||
import type { SelectedServer } from '../servers/data';
|
|
||||||
import { isServerWithId } from '../servers/data';
|
|
||||||
import type { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
|
||||||
import './AsideMenu.scss';
|
|
||||||
|
|
||||||
export interface AsideMenuProps {
|
|
||||||
selectedServer: SelectedServer;
|
|
||||||
showOnMobile?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AsideMenuItemProps extends NavLinkProps {
|
|
||||||
to: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
|
|
||||||
<NavLink
|
|
||||||
className={({ isActive }) => classNames('aside-menu__item', className, { 'aside-menu__item--selected': isActive })}
|
|
||||||
to={to}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
|
||||||
{ selectedServer, showOnMobile = false }: AsideMenuProps,
|
|
||||||
) => {
|
|
||||||
const hasId = isServerWithId(selectedServer);
|
|
||||||
const serverId = hasId ? selectedServer.id : '';
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const asideClass = classNames('aside-menu', {
|
|
||||||
'aside-menu--hidden': !showOnMobile,
|
|
||||||
});
|
|
||||||
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className={asideClass}>
|
|
||||||
<nav className="nav flex-column aside-menu__nav">
|
|
||||||
<AsideMenuItem to={buildPath('/overview')}>
|
|
||||||
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
|
|
||||||
<span className="aside-menu__item-text">Overview</span>
|
|
||||||
</AsideMenuItem>
|
|
||||||
<AsideMenuItem
|
|
||||||
to={buildPath('/list-short-urls/1')}
|
|
||||||
className={classNames({ 'aside-menu__item--selected': pathname.match('/list-short-urls') !== null })}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon fixedWidth icon={listIcon} />
|
|
||||||
<span className="aside-menu__item-text">List short URLs</span>
|
|
||||||
</AsideMenuItem>
|
|
||||||
<AsideMenuItem to={buildPath('/create-short-url')}>
|
|
||||||
<FontAwesomeIcon fixedWidth icon={createIcon} flip="horizontal" />
|
|
||||||
<span className="aside-menu__item-text">Create short URL</span>
|
|
||||||
</AsideMenuItem>
|
|
||||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
|
||||||
<FontAwesomeIcon fixedWidth icon={tagsIcon} />
|
|
||||||
<span className="aside-menu__item-text">Manage tags</span>
|
|
||||||
</AsideMenuItem>
|
|
||||||
<AsideMenuItem to={buildPath('/manage-domains')}>
|
|
||||||
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
|
|
||||||
<span className="aside-menu__item-text">Manage domains</span>
|
|
||||||
</AsideMenuItem>
|
|
||||||
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
|
||||||
<FontAwesomeIcon fixedWidth icon={editIcon} />
|
|
||||||
<span className="aside-menu__item-text">Edit this server</span>
|
|
||||||
</AsideMenuItem>
|
|
||||||
{hasId && (
|
|
||||||
<DeleteServerButton
|
|
||||||
className="aside-menu__item aside-menu__item--danger"
|
|
||||||
textClassName="aside-menu__item-text"
|
|
||||||
server={selectedServer}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,17 +1,19 @@
|
||||||
import type { ReactNode } from 'react';
|
import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import type { PropsWithChildren, ReactNode } from 'react';
|
||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import { Button } from 'reactstrap';
|
import { Button } from 'reactstrap';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
|
||||||
|
|
||||||
interface ErrorHandlerState {
|
type ErrorHandlerProps = PropsWithChildren<{
|
||||||
|
location?: typeof window.location;
|
||||||
|
console?: typeof window.console;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type ErrorHandlerState = {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const ErrorHandler = (
|
export class ErrorHandler extends Component<ErrorHandlerProps, ErrorHandlerState> {
|
||||||
{ location }: Window,
|
public constructor(props: ErrorHandlerProps) {
|
||||||
{ error }: Console,
|
|
||||||
) => class extends Component<any, ErrorHandlerState> {
|
|
||||||
public constructor(props: object) {
|
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { hasError: false };
|
this.state = { hasError: false };
|
||||||
}
|
}
|
||||||
|
@ -21,13 +23,14 @@ export const ErrorHandler = (
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidCatch(e: Error): void {
|
public componentDidCatch(e: Error): void {
|
||||||
if (process.env.NODE_ENV !== 'development') {
|
const { console = globalThis.console } = this.props;
|
||||||
error(e);
|
console.error(e);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public render(): ReactNode {
|
public render(): ReactNode {
|
||||||
const { hasError } = this.state;
|
const { hasError } = this.state;
|
||||||
|
const { location = globalThis.location } = this.props;
|
||||||
|
|
||||||
if (hasError) {
|
if (hasError) {
|
||||||
return (
|
return (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
|
@ -44,4 +47,4 @@ export const ErrorHandler = (
|
||||||
const { children } = this.props;
|
const { children } = this.props;
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../utils/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
@import '../utils/mixins/vertical-align';
|
@import '../utils/mixins/vertical-align';
|
||||||
|
|
||||||
$mainCardWidth: 720px;
|
$mainCardWidth: 720px;
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
|
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { isEmpty, values } from 'ramda';
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
@ -16,14 +15,14 @@ interface HomeProps {
|
||||||
|
|
||||||
export const Home = ({ servers }: HomeProps) => {
|
export const Home = ({ servers }: HomeProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const serversList = values(servers);
|
const serversList = Object.values(servers);
|
||||||
const hasServers = !isEmpty(serversList);
|
const hasServers = serversList.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Try to redirect to the first server marked as auto-connect
|
// Try to redirect to the first server marked as auto-connect
|
||||||
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
|
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
|
||||||
autoConnectServer && navigate(`/server/${autoConnectServer.id}`);
|
autoConnectServer && navigate(`/server/${autoConnectServer.id}`);
|
||||||
}, []);
|
}, [serversList, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../utils/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
.main-header.main-header {
|
.main-header.main-header {
|
||||||
color: white;
|
color: white;
|
||||||
|
|
|
@ -1,23 +1,31 @@
|
||||||
import { faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import classNames from 'classnames';
|
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
import './MainHeader.scss';
|
import './MainHeader.scss';
|
||||||
|
|
||||||
export const MainHeader = (ServersDropdown: FC) => () => {
|
type MainHeaderDeps = {
|
||||||
const [isOpen, toggleOpen, , close] = useToggle();
|
ServersDropdown: FC;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MainHeader: FCWithDeps<{}, MainHeaderDeps> = () => {
|
||||||
|
const { ServersDropdown } = useDependencies(MainHeader);
|
||||||
|
const [isNotCollapsed, toggleCollapse, , collapse] = useToggle();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { pathname } = location;
|
const { pathname } = location;
|
||||||
|
|
||||||
useEffect(close, [location]);
|
// In mobile devices, collapse the navbar when location changes
|
||||||
|
useEffect(collapse, [location, collapse]);
|
||||||
|
|
||||||
const settingsPath = '/settings';
|
const settingsPath = '/settings';
|
||||||
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
const toggleClass = clsx('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isNotCollapsed });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
||||||
|
@ -25,11 +33,11 @@ export const MainHeader = (ServersDropdown: FC) => () => {
|
||||||
<ShlinkLogo className="main-header__brand-logo" color="white" /> Shlink
|
<ShlinkLogo className="main-header__brand-logo" color="white" /> Shlink
|
||||||
</NavbarBrand>
|
</NavbarBrand>
|
||||||
|
|
||||||
<NavbarToggler onClick={toggleOpen}>
|
<NavbarToggler onClick={toggleCollapse}>
|
||||||
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
||||||
</NavbarToggler>
|
</NavbarToggler>
|
||||||
|
|
||||||
<Collapse navbar isOpen={isOpen}>
|
<Collapse navbar isOpen={isNotCollapsed}>
|
||||||
<Nav navbar className="ms-auto">
|
<Nav navbar className="ms-auto">
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
|
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
|
||||||
|
@ -42,3 +50,5 @@ export const MainHeader = (ServersDropdown: FC) => () => {
|
||||||
</Navbar>
|
</Navbar>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const MainHeaderFactory = componentFactory(MainHeader, ['ServersDropdown']);
|
||||||
|
|
|
@ -1,37 +0,0 @@
|
||||||
@import '../utils/base';
|
|
||||||
|
|
||||||
.menu-layout__swipeable {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-layout__swipeable-inner {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-layout__burger-icon {
|
|
||||||
display: none;
|
|
||||||
transition: color 300ms;
|
|
||||||
position: fixed;
|
|
||||||
top: 18px;
|
|
||||||
z-index: 1035;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
color: rgb(255 255 255 / .5);
|
|
||||||
|
|
||||||
@media (max-width: $smMax) {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-layout__burger-icon--active {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-layout__container.menu-layout__container {
|
|
||||||
padding: 20px 0 0;
|
|
||||||
min-height: 100%;
|
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
|
||||||
padding: 30px 0 0 $asideMenuWidth;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,87 +0,0 @@
|
||||||
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import type { FC } from 'react';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
|
||||||
import { isReachableServer } from '../servers/data';
|
|
||||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
|
||||||
import { useFeature } from '../utils/helpers/features';
|
|
||||||
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
|
|
||||||
import type { AsideMenuProps } from './AsideMenu';
|
|
||||||
import { NotFound } from './NotFound';
|
|
||||||
import './MenuLayout.scss';
|
|
||||||
|
|
||||||
interface MenuLayoutProps {
|
|
||||||
sidebarPresent: Function;
|
|
||||||
sidebarNotPresent: Function;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MenuLayout = (
|
|
||||||
TagsList: FC,
|
|
||||||
ShortUrlsList: FC,
|
|
||||||
AsideMenu: FC<AsideMenuProps>,
|
|
||||||
CreateShortUrl: FC,
|
|
||||||
ShortUrlVisits: FC,
|
|
||||||
TagVisits: FC,
|
|
||||||
DomainVisits: FC,
|
|
||||||
OrphanVisits: FC,
|
|
||||||
NonOrphanVisits: FC,
|
|
||||||
ServerError: FC,
|
|
||||||
Overview: FC,
|
|
||||||
EditShortUrl: FC,
|
|
||||||
ManageDomains: FC,
|
|
||||||
) => withSelectedServer<MenuLayoutProps>(({ selectedServer, sidebarNotPresent, sidebarPresent }) => {
|
|
||||||
const location = useLocation();
|
|
||||||
const [sidebarVisible, toggleSidebar, showSidebar, hideSidebar] = useToggle();
|
|
||||||
const showContent = isReachableServer(selectedServer);
|
|
||||||
|
|
||||||
useEffect(() => hideSidebar(), [location]);
|
|
||||||
useEffect(() => {
|
|
||||||
showContent && sidebarPresent();
|
|
||||||
return () => sidebarNotPresent();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!showContent) {
|
|
||||||
return <ServerError />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const addNonOrphanVisitsRoute = useFeature('nonOrphanVisits', selectedServer);
|
|
||||||
const addDomainVisitsRoute = useFeature('domainVisits', selectedServer);
|
|
||||||
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
|
||||||
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
|
||||||
|
|
||||||
<div {...swipeableProps} className="menu-layout__swipeable">
|
|
||||||
<div className="menu-layout__swipeable-inner">
|
|
||||||
<AsideMenu selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
|
||||||
<div className="menu-layout__container" onClick={() => hideSidebar()}>
|
|
||||||
<div className="container-xl">
|
|
||||||
<Routes>
|
|
||||||
<Route index element={<Navigate replace to="overview" />} />
|
|
||||||
<Route path="/overview" element={<Overview />} />
|
|
||||||
<Route path="/list-short-urls/:page" element={<ShortUrlsList />} />
|
|
||||||
<Route path="/create-short-url" element={<CreateShortUrl />} />
|
|
||||||
<Route path="/short-code/:shortCode/visits/*" element={<ShortUrlVisits />} />
|
|
||||||
<Route path="/short-code/:shortCode/edit" element={<EditShortUrl />} />
|
|
||||||
<Route path="/tag/:tag/visits/*" element={<TagVisits />} />
|
|
||||||
{addDomainVisitsRoute && <Route path="/domain/:domain/visits/*" element={<DomainVisits />} />}
|
|
||||||
<Route path="/orphan-visits/*" element={<OrphanVisits />} />
|
|
||||||
{addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />}
|
|
||||||
<Route path="/manage-tags" element={<TagsList />} />
|
|
||||||
<Route path="/manage-domains" element={<ManageDomains />} />
|
|
||||||
<Route
|
|
||||||
path="*"
|
|
||||||
element={<NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
|
||||||
/>
|
|
||||||
</Routes>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}, ServerError);
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../utils/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
.no-menu-wrapper {
|
.no-menu-wrapper {
|
||||||
padding: 15px 0 0;
|
padding: 15px 0 0;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
|
import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC, PropsWithChildren } from 'react';
|
import type { FC, PropsWithChildren } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
|
||||||
|
|
||||||
type NotFoundProps = PropsWithChildren<{ to?: string }>;
|
type NotFoundProps = PropsWithChildren<{ to?: string }>;
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ import type { FC, PropsWithChildren } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
export const ScrollToTop: FC<PropsWithChildren<unknown>> = ({ children }) => {
|
export const ScrollToTop: FC<PropsWithChildren> = ({ children }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
import { pipe } from 'ramda';
|
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import type { SelectedServer } from '../servers/data';
|
import type { SelectedServer } from '../servers/data';
|
||||||
import { isReachableServer } from '../servers/data';
|
import { isReachableServer } from '../servers/data';
|
||||||
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
||||||
|
|
||||||
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
||||||
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
|
const normalizeVersion = (version: string) => versionToPrintable(versionToSemVer(version));
|
||||||
|
|
||||||
export interface ShlinkVersionsProps {
|
export interface ShlinkVersionsProps {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../utils/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
.shlink-versions-container--with-sidebar {
|
.shlink-versions-container--with-sidebar {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
|
|
|
@ -1,17 +1,22 @@
|
||||||
import classNames from 'classnames';
|
import { clsx } from 'clsx';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import type { SelectedServer } from '../servers/data';
|
import type { SelectedServer } from '../servers/data';
|
||||||
import type { Sidebar } from './reducers/sidebar';
|
|
||||||
import { ShlinkVersions } from './ShlinkVersions';
|
import { ShlinkVersions } from './ShlinkVersions';
|
||||||
import './ShlinkVersionsContainer.scss';
|
import './ShlinkVersionsContainer.scss';
|
||||||
|
|
||||||
export interface ShlinkVersionsContainerProps {
|
export type ShlinkVersionsContainerProps = {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
sidebar: Sidebar;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export const ShlinkVersionsContainer = ({ selectedServer, sidebar }: ShlinkVersionsContainerProps) => {
|
const SHLINK_CONTAINER_PATH_PATTERN = /^\/server\/[a-zA-Z0-9-]*\/(?!edit)/;
|
||||||
const classes = classNames('text-center', {
|
|
||||||
'shlink-versions-container--with-sidebar': sidebar.sidebarPresent,
|
export const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const withPadding = useMemo(() => SHLINK_CONTAINER_PATH_PATTERN.test(pathname), [pathname]);
|
||||||
|
|
||||||
|
const classes = clsx('text-center', {
|
||||||
|
'shlink-versions-container--with-sidebar': withPadding,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
62
src/common/ShlinkWebComponentContainer.tsx
Normal file
62
src/common/ShlinkWebComponentContainer.tsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import type { Settings, ShlinkWebComponentType, TagColorsStorage } from '@shlinkio/shlink-web-component';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import type { ShlinkApiClientBuilder } from '../api/services/ShlinkApiClientBuilder';
|
||||||
|
import type { FCWithDeps } from '../container/utils';
|
||||||
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
|
import { isReachableServer } from '../servers/data';
|
||||||
|
import type { WithSelectedServerProps } from '../servers/helpers/withSelectedServer';
|
||||||
|
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||||
|
import { NotFound } from './NotFound';
|
||||||
|
|
||||||
|
type ShlinkWebComponentContainerProps = WithSelectedServerProps & {
|
||||||
|
settings: Settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShlinkWebComponentContainerDeps = {
|
||||||
|
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||||
|
TagColorsStorage: TagColorsStorage,
|
||||||
|
ShlinkWebComponent: ShlinkWebComponentType,
|
||||||
|
ServerError: FC,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ShlinkWebComponentContainer: FCWithDeps<
|
||||||
|
ShlinkWebComponentContainerProps,
|
||||||
|
ShlinkWebComponentContainerDeps
|
||||||
|
// FIXME Using `memo` here to solve a flickering effect in charts.
|
||||||
|
// memo is probably not the right solution. The root cause is the withSelectedServer HOC, but I couldn't fix the
|
||||||
|
// extra rendering there.
|
||||||
|
// This should be revisited at some point.
|
||||||
|
> = withSelectedServer(memo(({ selectedServer, settings }) => {
|
||||||
|
const {
|
||||||
|
buildShlinkApiClient,
|
||||||
|
TagColorsStorage: tagColorsStorage,
|
||||||
|
ShlinkWebComponent,
|
||||||
|
ServerError,
|
||||||
|
} = useDependencies(ShlinkWebComponentContainer);
|
||||||
|
|
||||||
|
if (!isReachableServer(selectedServer)) {
|
||||||
|
return <ServerError />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const routesPrefix = `/server/${selectedServer.id}`;
|
||||||
|
return (
|
||||||
|
<ShlinkWebComponent
|
||||||
|
serverVersion={selectedServer.version}
|
||||||
|
apiClient={buildShlinkApiClient(selectedServer)}
|
||||||
|
settings={settings}
|
||||||
|
routesPrefix={routesPrefix}
|
||||||
|
tagColorsStorage={tagColorsStorage}
|
||||||
|
createNotFound={(nonPrefixedHomePath) => (
|
||||||
|
<NotFound to={`${routesPrefix}${nonPrefixedHomePath}`}>List short URLs</NotFound>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ShlinkWebComponentContainerFactory = componentFactory(ShlinkWebComponentContainer, [
|
||||||
|
'buildShlinkApiClient',
|
||||||
|
'TagColorsStorage',
|
||||||
|
'ShlinkWebComponent',
|
||||||
|
'ServerError',
|
||||||
|
]);
|
|
@ -1,3 +0,0 @@
|
||||||
.simple-paginator {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
|
@ -1,51 +0,0 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import type { FC } from 'react';
|
|
||||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
|
||||||
import type {
|
|
||||||
NumberOrEllipsis } from '../utils/helpers/pagination';
|
|
||||||
import {
|
|
||||||
keyForPage,
|
|
||||||
pageIsEllipsis,
|
|
||||||
prettifyPageNumber,
|
|
||||||
progressivePagination,
|
|
||||||
} from '../utils/helpers/pagination';
|
|
||||||
import './SimplePaginator.scss';
|
|
||||||
|
|
||||||
interface SimplePaginatorProps {
|
|
||||||
pagesCount: number;
|
|
||||||
currentPage: number;
|
|
||||||
setCurrentPage: (currentPage: number) => void;
|
|
||||||
centered?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SimplePaginator: FC<SimplePaginatorProps> = (
|
|
||||||
{ pagesCount, currentPage, setCurrentPage, centered = true },
|
|
||||||
) => {
|
|
||||||
if (pagesCount < 2) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onClick = (page: NumberOrEllipsis) => () => !pageIsEllipsis(page) && setCurrentPage(page);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pagination listClassName={classNames('flex-wrap mb-0 simple-paginator', { 'justify-content-center': centered })}>
|
|
||||||
<PaginationItem disabled={currentPage <= 1}>
|
|
||||||
<PaginationLink previous tag="span" onClick={onClick(currentPage - 1)} />
|
|
||||||
</PaginationItem>
|
|
||||||
{progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
|
|
||||||
<PaginationItem
|
|
||||||
key={keyForPage(pageNumber, index)}
|
|
||||||
disabled={pageIsEllipsis(pageNumber)}
|
|
||||||
active={currentPage === pageNumber}
|
|
||||||
>
|
|
||||||
<PaginationLink role="link" tag="span" onClick={onClick(pageNumber)}>
|
|
||||||
{prettifyPageNumber(pageNumber)}
|
|
||||||
</PaginationLink>
|
|
||||||
</PaginationItem>
|
|
||||||
))}
|
|
||||||
<PaginationItem disabled={currentPage >= pagesCount}>
|
|
||||||
<PaginationLink next tag="span" onClick={onClick(currentPage + 1)} />
|
|
||||||
</PaginationItem>
|
|
||||||
</Pagination>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { MAIN_COLOR } from '../../utils/theme';
|
import { MAIN_COLOR } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
|
||||||
export interface ShlinkLogoProps {
|
export interface ShlinkLogoProps {
|
||||||
color?: string;
|
color?: string;
|
||||||
|
|
|
@ -1,159 +0,0 @@
|
||||||
@import '../utils/base';
|
|
||||||
|
|
||||||
.react-tags {
|
|
||||||
position: relative;
|
|
||||||
padding: 5px 0 0 6px;
|
|
||||||
border-radius: .5rem;
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
border: 1px solid var(--input-border-color);
|
|
||||||
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
|
||||||
|
|
||||||
/* shared font styles */
|
|
||||||
font-size: 1em;
|
|
||||||
line-height: 1.2;
|
|
||||||
|
|
||||||
/* clicking anywhere will focus the input */
|
|
||||||
cursor: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group > .react-tags {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
width: 1%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card .react-tags {
|
|
||||||
background-color: var(--input-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags.is-focused {
|
|
||||||
box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__tag {
|
|
||||||
font-size: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__selected {
|
|
||||||
display: inline;
|
|
||||||
vertical-align: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__selected-tag {
|
|
||||||
display: inline-block;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0 6px 6px 0;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border: 1px solid var(--input-border-color);
|
|
||||||
border-radius: .25rem;
|
|
||||||
background: #f1f1f1;
|
|
||||||
|
|
||||||
/* match the font styles */
|
|
||||||
font-size: inherit;
|
|
||||||
line-height: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__selected-tag:after {
|
|
||||||
content: '\2715';
|
|
||||||
color: #aaaaaa;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__selected-tag:hover,
|
|
||||||
.react-tags__selected-tag:focus {
|
|
||||||
border-color: var(--input-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__search {
|
|
||||||
display: inline-block;
|
|
||||||
|
|
||||||
/* match tag layout */
|
|
||||||
padding: 6px 2px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
|
|
||||||
/* prevent autoresize overflowing the container */
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: $smMin) {
|
|
||||||
.react-tags__search {
|
|
||||||
/* this will become the offsetParent for suggestions */
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__search-input {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: inherit;
|
|
||||||
color: var(--input-text-color);
|
|
||||||
background-color: inherit;
|
|
||||||
|
|
||||||
/* prevent autoresize overflowing the container */
|
|
||||||
max-width: 100%;
|
|
||||||
|
|
||||||
/* remove styles and layout from this element */
|
|
||||||
margin: 0 0 0 7px;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__search-input::placeholder {
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__search-input::-ms-clear {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__suggestions {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: $smMin) {
|
|
||||||
.react-tags__suggestions {
|
|
||||||
width: 240px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__suggestions ul {
|
|
||||||
margin: 4px -1px;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
background: var(--primary-color);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: .25rem;
|
|
||||||
box-shadow: 0 2px 6px rgb(0 0 0 / .2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__suggestions li {
|
|
||||||
padding: 8px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__suggestions li:not(:last-child) {
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__suggestions li mark {
|
|
||||||
text-decoration: underline;
|
|
||||||
background: none;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__suggestions li:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: var(--active-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__suggestions li.is-active {
|
|
||||||
background-color: var(--active-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__suggestions li.is-disabled {
|
|
||||||
opacity: .5;
|
|
||||||
cursor: auto;
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
import { createSlice } from '@reduxjs/toolkit';
|
|
||||||
|
|
||||||
export interface Sidebar {
|
|
||||||
sidebarPresent: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: Sidebar = {
|
|
||||||
sidebarPresent: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { actions, reducer } = createSlice({
|
|
||||||
name: 'shlink/sidebar',
|
|
||||||
initialState,
|
|
||||||
reducers: {
|
|
||||||
sidebarPresent: () => ({ sidebarPresent: true }),
|
|
||||||
sidebarNotPresent: () => ({ sidebarPresent: false }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const { sidebarPresent, sidebarNotPresent } = actions;
|
|
||||||
|
|
||||||
export const sidebarReducer = reducer;
|
|
|
@ -1,42 +0,0 @@
|
||||||
import type { Fetch } from '../../utils/types';
|
|
||||||
|
|
||||||
const applicationJsonHeader = { 'Content-Type': 'application/json' };
|
|
||||||
const withJsonContentType = (options?: RequestInit): RequestInit | undefined => {
|
|
||||||
if (!options?.body) {
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
return options ? {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
...(options.headers ?? {}),
|
|
||||||
...applicationJsonHeader,
|
|
||||||
},
|
|
||||||
} : {
|
|
||||||
headers: applicationJsonHeader,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export class HttpClient {
|
|
||||||
constructor(private readonly fetch: Fetch) {}
|
|
||||||
|
|
||||||
public readonly fetchJson = <T>(url: string, options?: RequestInit): Promise<T> =>
|
|
||||||
this.fetch(url, withJsonContentType(options)).then(async (resp) => {
|
|
||||||
const json = await resp.json();
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
throw json;
|
|
||||||
}
|
|
||||||
|
|
||||||
return json as T;
|
|
||||||
});
|
|
||||||
|
|
||||||
public readonly fetchEmpty = (url: string, options?: RequestInit): Promise<void> =>
|
|
||||||
this.fetch(url, withJsonContentType(options)).then(async (resp) => {
|
|
||||||
if (!resp.ok) {
|
|
||||||
throw await resp.json();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
public readonly fetchBlob = (url: string): Promise<Blob> => this.fetch(url).then((resp) => resp.blob());
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
import { saveUrl } from '../../utils/helpers/files';
|
|
||||||
import type { HttpClient } from './HttpClient';
|
|
||||||
|
|
||||||
export class ImageDownloader {
|
|
||||||
public constructor(private readonly httpClient: HttpClient, private readonly window: Window) {}
|
|
||||||
|
|
||||||
public async saveImage(imgUrl: string, filename: string): Promise<void> {
|
|
||||||
const data = await this.httpClient.fetchBlob(imgUrl);
|
|
||||||
const url = URL.createObjectURL(data);
|
|
||||||
|
|
||||||
saveUrl(this.window, url, filename);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,29 +0,0 @@
|
||||||
import type { ExportableShortUrl } from '../../short-urls/data';
|
|
||||||
import type { JsonToCsv } from '../../utils/helpers/csvjson';
|
|
||||||
import { saveCsv } from '../../utils/helpers/files';
|
|
||||||
import type { NormalizedVisit } from '../../visits/types';
|
|
||||||
|
|
||||||
export class ReportExporter {
|
|
||||||
public constructor(private readonly window: Window, private readonly jsonToCsv: JsonToCsv) {}
|
|
||||||
|
|
||||||
public readonly exportVisits = (filename: string, visits: NormalizedVisit[]) => {
|
|
||||||
if (!visits.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.exportCsv(filename, visits);
|
|
||||||
};
|
|
||||||
|
|
||||||
public readonly exportShortUrls = (shortUrls: ExportableShortUrl[]) => {
|
|
||||||
if (!shortUrls.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.exportCsv('short_urls.csv', shortUrls);
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly exportCsv = (filename: string, rows: object[]) => {
|
|
||||||
const csv = this.jsonToCsv(rows);
|
|
||||||
saveCsv(this.window, csv, filename);
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,64 +1,37 @@
|
||||||
|
import { FetchHttpClient } from '@shlinkio/shlink-js-sdk/browser';
|
||||||
|
import { ShlinkWebComponent } from '@shlinkio/shlink-web-component';
|
||||||
import type Bottle from 'bottlejs';
|
import type Bottle from 'bottlejs';
|
||||||
import type { ConnectDecorator } from '../../container/types';
|
import type { ConnectDecorator } from '../../container/types';
|
||||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||||
import { AsideMenu } from '../AsideMenu';
|
|
||||||
import { ErrorHandler } from '../ErrorHandler';
|
import { ErrorHandler } from '../ErrorHandler';
|
||||||
import { Home } from '../Home';
|
import { Home } from '../Home';
|
||||||
import { MainHeader } from '../MainHeader';
|
import { MainHeaderFactory } from '../MainHeader';
|
||||||
import { MenuLayout } from '../MenuLayout';
|
|
||||||
import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar';
|
|
||||||
import { ScrollToTop } from '../ScrollToTop';
|
import { ScrollToTop } from '../ScrollToTop';
|
||||||
import { ShlinkVersionsContainer } from '../ShlinkVersionsContainer';
|
import { ShlinkVersionsContainer } from '../ShlinkVersionsContainer';
|
||||||
import { HttpClient } from './HttpClient';
|
import { ShlinkWebComponentContainerFactory } from '../ShlinkWebComponentContainer';
|
||||||
import { ImageDownloader } from './ImageDownloader';
|
|
||||||
import { ReportExporter } from './ReportExporter';
|
|
||||||
|
|
||||||
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Services
|
// Services
|
||||||
bottle.constant('window', window);
|
bottle.constant('window', window);
|
||||||
bottle.constant('console', console);
|
bottle.constant('console', console);
|
||||||
bottle.constant('fetch', window.fetch.bind(window));
|
bottle.constant('fetch', window.fetch.bind(window));
|
||||||
|
bottle.service('HttpClient', FetchHttpClient, 'fetch');
|
||||||
bottle.service('HttpClient', HttpClient, 'fetch');
|
|
||||||
bottle.service('ImageDownloader', ImageDownloader, 'HttpClient', 'window');
|
|
||||||
bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv');
|
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('ScrollToTop', () => ScrollToTop);
|
bottle.serviceFactory('ScrollToTop', () => ScrollToTop);
|
||||||
|
|
||||||
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
|
bottle.factory('MainHeader', MainHeaderFactory);
|
||||||
|
|
||||||
bottle.serviceFactory('Home', () => Home);
|
bottle.serviceFactory('Home', () => Home);
|
||||||
bottle.decorator('Home', withoutSelectedServer);
|
bottle.decorator('Home', withoutSelectedServer);
|
||||||
bottle.decorator('Home', connect(['servers'], ['resetSelectedServer']));
|
bottle.decorator('Home', connect(['servers'], ['resetSelectedServer']));
|
||||||
|
|
||||||
bottle.serviceFactory(
|
bottle.serviceFactory('ShlinkWebComponent', () => ShlinkWebComponent);
|
||||||
'MenuLayout',
|
bottle.factory('ShlinkWebComponentContainer', ShlinkWebComponentContainerFactory);
|
||||||
MenuLayout,
|
bottle.decorator('ShlinkWebComponentContainer', connect(['selectedServer', 'settings'], ['selectServer']));
|
||||||
'TagsList',
|
|
||||||
'ShortUrlsList',
|
|
||||||
'AsideMenu',
|
|
||||||
'CreateShortUrl',
|
|
||||||
'ShortUrlVisits',
|
|
||||||
'TagVisits',
|
|
||||||
'DomainVisits',
|
|
||||||
'OrphanVisits',
|
|
||||||
'NonOrphanVisits',
|
|
||||||
'ServerError',
|
|
||||||
'Overview',
|
|
||||||
'EditShortUrl',
|
|
||||||
'ManageDomains',
|
|
||||||
);
|
|
||||||
bottle.decorator('MenuLayout', connect(['selectedServer'], ['selectServer', 'sidebarPresent', 'sidebarNotPresent']));
|
|
||||||
|
|
||||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
|
||||||
|
|
||||||
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
|
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
|
||||||
bottle.decorator('ShlinkVersionsContainer', connect(['selectedServer', 'sidebar']));
|
bottle.decorator('ShlinkVersionsContainer', connect(['selectedServer']));
|
||||||
|
|
||||||
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
bottle.serviceFactory('ErrorHandler', () => ErrorHandler);
|
||||||
|
|
||||||
// Actions
|
|
||||||
bottle.serviceFactory('sidebarPresent', () => sidebarPresent);
|
|
||||||
bottle.serviceFactory('sidebarNotPresent', () => sidebarNotPresent);
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,18 +1,12 @@
|
||||||
import type { IContainer } from 'bottlejs';
|
import type { IContainer } from 'bottlejs';
|
||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import { pick } from 'ramda';
|
|
||||||
import { connect as reduxConnect } from 'react-redux';
|
import { connect as reduxConnect } from 'react-redux';
|
||||||
import { provideServices as provideApiServices } from '../api/services/provideServices';
|
import { provideServices as provideApiServices } from '../api/services/provideServices';
|
||||||
import { provideServices as provideAppServices } from '../app/services/provideServices';
|
import { provideServices as provideAppServices } from '../app/services/provideServices';
|
||||||
import { provideServices as provideCommonServices } from '../common/services/provideServices';
|
import { provideServices as provideCommonServices } from '../common/services/provideServices';
|
||||||
import { provideServices as provideDomainsServices } from '../domains/services/provideServices';
|
|
||||||
import { provideServices as provideMercureServices } from '../mercure/services/provideServices';
|
|
||||||
import { provideServices as provideServersServices } from '../servers/services/provideServices';
|
import { provideServices as provideServersServices } from '../servers/services/provideServices';
|
||||||
import { provideServices as provideSettingsServices } from '../settings/services/provideServices';
|
import { provideServices as provideSettingsServices } from '../settings/services/provideServices';
|
||||||
import { provideServices as provideShortUrlsServices } from '../short-urls/services/provideServices';
|
|
||||||
import { provideServices as provideTagsServices } from '../tags/services/provideServices';
|
|
||||||
import { provideServices as provideUtilsServices } from '../utils/services/provideServices';
|
import { provideServices as provideUtilsServices } from '../utils/services/provideServices';
|
||||||
import { provideServices as provideVisitsServices } from '../visits/services/provideServices';
|
|
||||||
import type { ConnectDecorator } from './types';
|
import type { ConnectDecorator } from './types';
|
||||||
|
|
||||||
type LazyActionMap = Record<string, Function>;
|
type LazyActionMap = Record<string, Function>;
|
||||||
|
@ -23,25 +17,26 @@ export const { container } = bottle;
|
||||||
|
|
||||||
const lazyService = <T extends Function, K>(cont: IContainer, serviceName: string) =>
|
const lazyService = <T extends Function, K>(cont: IContainer, serviceName: string) =>
|
||||||
(...args: any[]) => (cont[serviceName] as T)(...args) as K;
|
(...args: any[]) => (cont[serviceName] as T)(...args) as K;
|
||||||
|
|
||||||
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
|
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
|
||||||
...map,
|
...map,
|
||||||
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
||||||
[actionName]: lazyService(container, actionName),
|
[actionName]: lazyService(container, actionName),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pickProps = (propsToPick: string[]) => (obj: any) => Object.fromEntries(
|
||||||
|
propsToPick.map((key) => [key, obj[key]]),
|
||||||
|
);
|
||||||
|
|
||||||
const connect: ConnectDecorator = (propsFromState: string[] | null, actionServiceNames: string[] = []) =>
|
const connect: ConnectDecorator = (propsFromState: string[] | null, actionServiceNames: string[] = []) =>
|
||||||
reduxConnect(
|
reduxConnect(
|
||||||
propsFromState ? pick(propsFromState) : null,
|
propsFromState ? pickProps(propsFromState) : null,
|
||||||
actionServiceNames.reduce(mapActionService, {}),
|
actionServiceNames.reduce(mapActionService, {}),
|
||||||
);
|
);
|
||||||
|
|
||||||
provideAppServices(bottle, connect);
|
provideAppServices(bottle, connect);
|
||||||
provideCommonServices(bottle, connect);
|
provideCommonServices(bottle, connect);
|
||||||
provideApiServices(bottle);
|
provideApiServices(bottle);
|
||||||
provideShortUrlsServices(bottle, connect);
|
|
||||||
provideServersServices(bottle, connect);
|
provideServersServices(bottle, connect);
|
||||||
provideTagsServices(bottle, connect);
|
|
||||||
provideVisitsServices(bottle, connect);
|
|
||||||
provideUtilsServices(bottle);
|
provideUtilsServices(bottle);
|
||||||
provideMercureServices(bottle);
|
|
||||||
provideSettingsServices(bottle, connect);
|
provideSettingsServices(bottle, connect);
|
||||||
provideDomainsServices(bottle, connect);
|
|
||||||
|
|
|
@ -21,6 +21,5 @@ export const setUpStore = (container: IContainer) => configureStore({
|
||||||
preloadedState,
|
preloadedState,
|
||||||
middleware: (defaultMiddlewaresIncludingReduxThunk) =>
|
middleware: (defaultMiddlewaresIncludingReduxThunk) =>
|
||||||
defaultMiddlewaresIncludingReduxThunk({ immutableCheck: false, serializableCheck: false }) // State is too big for these
|
defaultMiddlewaresIncludingReduxThunk({ immutableCheck: false, serializableCheck: false }) // State is too big for these
|
||||||
.prepend(container.selectServerListener.middleware)
|
|
||||||
.concat(save(localStorageConfig)),
|
.concat(save(localStorageConfig)),
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,44 +1,11 @@
|
||||||
import type { Sidebar } from '../common/reducers/sidebar';
|
import type { Settings } from '@shlinkio/shlink-web-component';
|
||||||
import type { DomainsList } from '../domains/reducers/domainsList';
|
|
||||||
import type { MercureInfo } from '../mercure/reducers/mercureInfo';
|
|
||||||
import type { SelectedServer, ServersMap } from '../servers/data';
|
import type { SelectedServer, ServersMap } from '../servers/data';
|
||||||
import type { Settings } from '../settings/reducers/settings';
|
|
||||||
import type { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
|
|
||||||
import type { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
|
|
||||||
import type { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
|
|
||||||
import type { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
|
|
||||||
import type { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
|
|
||||||
import type { TagDeletion } from '../tags/reducers/tagDelete';
|
|
||||||
import type { TagEdition } from '../tags/reducers/tagEdit';
|
|
||||||
import type { TagsList } from '../tags/reducers/tagsList';
|
|
||||||
import type { DomainVisits } from '../visits/reducers/domainVisits';
|
|
||||||
import type { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
|
||||||
import type { TagVisits } from '../visits/reducers/tagVisits';
|
|
||||||
import type { VisitsInfo } from '../visits/reducers/types';
|
|
||||||
import type { VisitsOverview } from '../visits/reducers/visitsOverview';
|
|
||||||
|
|
||||||
export interface ShlinkState {
|
export interface ShlinkState {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
shortUrlsList: ShortUrlsList;
|
|
||||||
shortUrlCreation: ShortUrlCreation;
|
|
||||||
shortUrlDeletion: ShortUrlDeletion;
|
|
||||||
shortUrlEdition: ShortUrlEdition;
|
|
||||||
shortUrlVisits: ShortUrlVisits;
|
|
||||||
tagVisits: TagVisits;
|
|
||||||
domainVisits: DomainVisits;
|
|
||||||
orphanVisits: VisitsInfo;
|
|
||||||
nonOrphanVisits: VisitsInfo;
|
|
||||||
shortUrlDetail: ShortUrlDetail;
|
|
||||||
tagsList: TagsList;
|
|
||||||
tagDelete: TagDeletion;
|
|
||||||
tagEdit: TagEdition;
|
|
||||||
mercureInfo: MercureInfo;
|
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
domainsList: DomainsList;
|
|
||||||
visitsOverview: VisitsOverview;
|
|
||||||
appUpdated: boolean;
|
appUpdated: boolean;
|
||||||
sidebar: Sidebar;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||||
|
|
29
src/container/utils.ts
Normal file
29
src/container/utils.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import type { IContainer } from 'bottlejs';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
|
export type FCWithDeps<Props, Deps> = FC<Props> & Partial<Deps>;
|
||||||
|
|
||||||
|
export function useDependencies<Deps>(obj: Deps): Omit<Required<Deps>, keyof FC> {
|
||||||
|
const depsRef = useRef(obj as Omit<Required<Deps>, keyof FC>);
|
||||||
|
return depsRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function componentFactory<Deps, CompType = Omit<Partial<Deps>, keyof FC>>(
|
||||||
|
Component: CompType,
|
||||||
|
deps: ReadonlyArray<keyof CompType>,
|
||||||
|
) {
|
||||||
|
return (container: IContainer, console = globalThis.console) => {
|
||||||
|
deps.forEach((dep) => {
|
||||||
|
const resolvedDependency = container[dep as string];
|
||||||
|
if (!resolvedDependency && process.env.NODE_ENV !== 'production') {
|
||||||
|
console.error(`[Debug] Could not find "${dep as string}" dependency in container`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
Component[dep] = resolvedDependency;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Component;
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,65 +0,0 @@
|
||||||
import { faDotCircle as defaultDomainIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import type { FC } from 'react';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
|
||||||
import type { ShlinkDomainRedirects } from '../api/types';
|
|
||||||
import type { SelectedServer } from '../servers/data';
|
|
||||||
import type { OptionalString } from '../utils/utils';
|
|
||||||
import type { Domain } from './data';
|
|
||||||
import { DomainDropdown } from './helpers/DomainDropdown';
|
|
||||||
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
|
|
||||||
import type { EditDomainRedirects } from './reducers/domainRedirects';
|
|
||||||
|
|
||||||
interface DomainRowProps {
|
|
||||||
domain: Domain;
|
|
||||||
defaultRedirects?: ShlinkDomainRedirects;
|
|
||||||
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
|
||||||
checkDomainHealth: (domain: string) => void;
|
|
||||||
selectedServer: SelectedServer;
|
|
||||||
}
|
|
||||||
|
|
||||||
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, checkDomainHealth, defaultRedirects, selectedServer },
|
|
||||||
) => {
|
|
||||||
const { domain: authority, isDefault, redirects, status } = domain;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
checkDomainHealth(domain.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-lg-center" data-th="Status">
|
|
||||||
<DomainStatusIcon status={status} />
|
|
||||||
</td>
|
|
||||||
<td className="responsive-table__cell text-end">
|
|
||||||
<DomainDropdown domain={domain} editDomainRedirects={editDomainRedirects} selectedServer={selectedServer} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,19 +0,0 @@
|
||||||
@import '../utils/base';
|
|
||||||
@import '../utils/mixins/vertical-align';
|
|
||||||
|
|
||||||
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn,
|
|
||||||
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:hover,
|
|
||||||
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:active {
|
|
||||||
color: $textPlaceholder !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active,
|
|
||||||
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:hover,
|
|
||||||
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:active {
|
|
||||||
color: var(--input-text-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.domains-dropdown__back-btn.domains-dropdown__back-btn,
|
|
||||||
.domains-dropdown__back-btn.domains-dropdown__back-btn:hover {
|
|
||||||
border-color: var(--border-color);
|
|
||||||
}
|
|
|
@ -1,74 +0,0 @@
|
||||||
import { faUndo } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { isEmpty, pipe } from 'ramda';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import type { InputProps } from 'reactstrap';
|
|
||||||
import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip } from 'reactstrap';
|
|
||||||
import { DropdownBtn } from '../utils/DropdownBtn';
|
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
|
||||||
import type { DomainsList } from './reducers/domainsList';
|
|
||||||
import './DomainSelector.scss';
|
|
||||||
|
|
||||||
export interface DomainSelectorProps extends Omit<InputProps, 'onChange'> {
|
|
||||||
value?: string;
|
|
||||||
onChange: (domain: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DomainSelectorConnectProps extends DomainSelectorProps {
|
|
||||||
listDomains: Function;
|
|
||||||
domainsList: DomainsList;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DomainSelector = ({ listDomains, value, domainsList, onChange }: DomainSelectorConnectProps) => {
|
|
||||||
const [inputDisplayed,, showInput, hideInput] = useToggle();
|
|
||||||
const { domains } = domainsList;
|
|
||||||
const valueIsEmpty = isEmpty(value);
|
|
||||||
const unselectDomain = () => onChange('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
listDomains();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return inputDisplayed ? (
|
|
||||||
<InputGroup>
|
|
||||||
<Input
|
|
||||||
value={value ?? ''}
|
|
||||||
placeholder="Domain"
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
id="backToDropdown"
|
|
||||||
outline
|
|
||||||
type="button"
|
|
||||||
className="domains-dropdown__back-btn"
|
|
||||||
aria-label="Back to domains list"
|
|
||||||
onClick={pipe(unselectDomain, hideInput)}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faUndo} />
|
|
||||||
</Button>
|
|
||||||
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
|
|
||||||
Existing domains
|
|
||||||
</UncontrolledTooltip>
|
|
||||||
</InputGroup>
|
|
||||||
) : (
|
|
||||||
<DropdownBtn
|
|
||||||
text={valueIsEmpty ? 'Domain' : `Domain: ${value}`}
|
|
||||||
className={!valueIsEmpty ? 'domains-dropdown__toggle-btn--active' : 'domains-dropdown__toggle-btn'}
|
|
||||||
>
|
|
||||||
{domains.map(({ domain, isDefault }) => (
|
|
||||||
<DropdownItem
|
|
||||||
key={domain}
|
|
||||||
active={(value === domain || isDefault) && valueIsEmpty}
|
|
||||||
onClick={() => onChange(domain)}
|
|
||||||
>
|
|
||||||
{domain}
|
|
||||||
{isDefault && <span className="float-end text-muted">default</span>}
|
|
||||||
</DropdownItem>
|
|
||||||
))}
|
|
||||||
<DropdownItem divider />
|
|
||||||
<DropdownItem onClick={pipe(unselectDomain, showInput)}>
|
|
||||||
<i>New domain</i>
|
|
||||||
</DropdownItem>
|
|
||||||
</DropdownBtn>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,77 +0,0 @@
|
||||||
import type { FC } from 'react';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
|
||||||
import type { SelectedServer } from '../servers/data';
|
|
||||||
import { Message } from '../utils/Message';
|
|
||||||
import { Result } from '../utils/Result';
|
|
||||||
import { SearchField } from '../utils/SearchField';
|
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
|
||||||
import { DomainRow } from './DomainRow';
|
|
||||||
import type { EditDomainRedirects } from './reducers/domainRedirects';
|
|
||||||
import type { DomainsList } from './reducers/domainsList';
|
|
||||||
|
|
||||||
interface ManageDomainsProps {
|
|
||||||
listDomains: Function;
|
|
||||||
filterDomains: (searchTerm: string) => void;
|
|
||||||
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
|
||||||
checkDomainHealth: (domain: string) => void;
|
|
||||||
domainsList: DomainsList;
|
|
||||||
selectedServer: SelectedServer;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = ['', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', ''];
|
|
||||||
|
|
||||||
export const ManageDomains: FC<ManageDomainsProps> = (
|
|
||||||
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer },
|
|
||||||
) => {
|
|
||||||
const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList;
|
|
||||||
const resolvedDefaultRedirects = 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 responsive-table 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}
|
|
||||||
checkDomainHealth={checkDomainHealth}
|
|
||||||
defaultRedirects={resolvedDefaultRedirects}
|
|
||||||
selectedServer={selectedServer}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</SimpleCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SearchField className="mb-3" onChange={filterDomains} />
|
|
||||||
{renderContent()}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,7 +0,0 @@
|
||||||
import type { ShlinkDomain } from '../../api/types';
|
|
||||||
|
|
||||||
export type DomainStatus = 'validating' | 'valid' | 'invalid';
|
|
||||||
|
|
||||||
export interface Domain extends ShlinkDomain {
|
|
||||||
status: DomainStatus;
|
|
||||||
}
|
|
|
@ -1,51 +0,0 @@
|
||||||
import { faChartPie as pieChartIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import type { FC } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { DropdownItem } from 'reactstrap';
|
|
||||||
import type { SelectedServer } from '../../servers/data';
|
|
||||||
import { getServerId } from '../../servers/data';
|
|
||||||
import { useFeature } from '../../utils/helpers/features';
|
|
||||||
import { useToggle } from '../../utils/helpers/hooks';
|
|
||||||
import { RowDropdownBtn } from '../../utils/RowDropdownBtn';
|
|
||||||
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
|
|
||||||
import type { Domain } from '../data';
|
|
||||||
import type { EditDomainRedirects } from '../reducers/domainRedirects';
|
|
||||||
import { EditDomainRedirectsModal } from './EditDomainRedirectsModal';
|
|
||||||
|
|
||||||
interface DomainDropdownProps {
|
|
||||||
domain: Domain;
|
|
||||||
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
|
||||||
selectedServer: SelectedServer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedirects, selectedServer }) => {
|
|
||||||
const [isModalOpen, toggleModal] = useToggle();
|
|
||||||
const { isDefault } = domain;
|
|
||||||
const canBeEdited = !isDefault || useFeature('defaultDomainRedirectsEdition', selectedServer);
|
|
||||||
const withVisits = useFeature('domainVisits', selectedServer);
|
|
||||||
const serverId = getServerId(selectedServer);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RowDropdownBtn>
|
|
||||||
{withVisits && (
|
|
||||||
<DropdownItem
|
|
||||||
tag={Link}
|
|
||||||
to={`/server/${serverId}/domain/${domain.domain}${domain.isDefault ? `_${DEFAULT_DOMAIN}` : ''}/visits`}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
|
||||||
</DropdownItem>
|
|
||||||
)}
|
|
||||||
<DropdownItem disabled={!canBeEdited} onClick={!canBeEdited ? undefined : toggleModal}>
|
|
||||||
<FontAwesomeIcon fixedWidth icon={editIcon} /> Edit redirects
|
|
||||||
</DropdownItem>
|
|
||||||
|
|
||||||
<EditDomainRedirectsModal
|
|
||||||
domain={domain}
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
toggle={toggleModal}
|
|
||||||
editDomainRedirects={editDomainRedirects}
|
|
||||||
/>
|
|
||||||
</RowDropdownBtn>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,60 +0,0 @@
|
||||||
import {
|
|
||||||
faCheck as checkIcon,
|
|
||||||
faCircleNotch as loadingStatusIcon,
|
|
||||||
faTimes as invalidIcon,
|
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import type { FC } from 'react';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { ExternalLink } from 'react-external-link';
|
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
|
||||||
import { useElementRef } from '../../utils/helpers/hooks';
|
|
||||||
import type { MediaMatcher } from '../../utils/types';
|
|
||||||
import type { DomainStatus } from '../data';
|
|
||||||
|
|
||||||
interface DomainStatusIconProps {
|
|
||||||
status: DomainStatus;
|
|
||||||
matchMedia?: MediaMatcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => {
|
|
||||||
const ref = useElementRef<HTMLSpanElement>();
|
|
||||||
const matchesMobile = () => matchMedia('(max-width: 991px)').matches;
|
|
||||||
const [isMobile, setIsMobile] = useState<boolean>(matchesMobile());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const listener = () => setIsMobile(matchesMobile());
|
|
||||||
|
|
||||||
window.addEventListener('resize', listener);
|
|
||||||
|
|
||||||
return () => window.removeEventListener('resize', listener);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (status === 'validating') {
|
|
||||||
return <FontAwesomeIcon fixedWidth icon={loadingStatusIcon} spin />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span ref={ref}>
|
|
||||||
{status === 'valid'
|
|
||||||
? <FontAwesomeIcon fixedWidth icon={checkIcon} className="text-muted" />
|
|
||||||
: <FontAwesomeIcon fixedWidth icon={invalidIcon} className="text-danger" />}
|
|
||||||
</span>
|
|
||||||
<UncontrolledTooltip
|
|
||||||
target={ref}
|
|
||||||
placement={isMobile ? 'top-start' : 'left'}
|
|
||||||
autohide={status === 'valid'}
|
|
||||||
>
|
|
||||||
{status === 'valid' ? 'Congratulations! This domain is properly configured.' : (
|
|
||||||
<span>
|
|
||||||
Oops! There is some missing configuration, and short URLs shared with this domain will not work.
|
|
||||||
<br />
|
|
||||||
Check the <ExternalLink href="https://slnk.to/multi-domain-docs">documentation</ExternalLink> in order to
|
|
||||||
find out what is missing.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</UncontrolledTooltip>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,78 +0,0 @@
|
||||||
import type { FC } from 'react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
|
||||||
import type { ShlinkDomain } from '../../api/types';
|
|
||||||
import type { InputFormGroupProps } from '../../utils/forms/InputFormGroup';
|
|
||||||
import { InputFormGroup } from '../../utils/forms/InputFormGroup';
|
|
||||||
import { InfoTooltip } from '../../utils/InfoTooltip';
|
|
||||||
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
|
|
||||||
import type { EditDomainRedirects } from '../reducers/domainRedirects';
|
|
||||||
|
|
||||||
interface EditDomainRedirectsModalProps {
|
|
||||||
domain: ShlinkDomain;
|
|
||||||
isOpen: boolean;
|
|
||||||
toggle: () => void;
|
|
||||||
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
|
|
||||||
<InputFormGroup
|
|
||||||
{...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.domain,
|
|
||||||
redirects: {
|
|
||||||
baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect),
|
|
||||||
regular404Redirect: nonEmptyValueOrNull(regular404Redirect),
|
|
||||||
invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect),
|
|
||||||
},
|
|
||||||
}).then(toggle));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
|
||||||
<form name="domainRedirectsModal" onSubmit={handleSubmit}>
|
|
||||||
<ModalHeader toggle={toggle}>Edit redirects for <b>{domain.domain}</b></ModalHeader>
|
|
||||||
<ModalBody>
|
|
||||||
<FormGroup value={baseUrlRedirect} onChange={setBaseUrlRedirect}>
|
|
||||||
<InfoTooltip className="me-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="me-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="me-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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,22 +0,0 @@
|
||||||
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
|
||||||
import type { ShlinkDomainRedirects } from '../../api/types';
|
|
||||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
|
||||||
|
|
||||||
const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
|
|
||||||
|
|
||||||
export interface EditDomainRedirects {
|
|
||||||
domain: string;
|
|
||||||
redirects: ShlinkDomainRedirects;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const editDomainRedirects = (
|
|
||||||
buildShlinkApiClient: ShlinkApiClientBuilder,
|
|
||||||
) => createAsyncThunk(
|
|
||||||
EDIT_DOMAIN_REDIRECTS,
|
|
||||||
async ({ domain, redirects: providedRedirects }: EditDomainRedirects, { getState }): Promise<EditDomainRedirects> => {
|
|
||||||
const { editDomainRedirects: shlinkEditDomainRedirects } = buildShlinkApiClient(getState);
|
|
||||||
const redirects = await shlinkEditDomainRedirects({ domain, ...providedRedirects });
|
|
||||||
|
|
||||||
return { domain, redirects };
|
|
||||||
},
|
|
||||||
);
|
|
|
@ -1,126 +0,0 @@
|
||||||
import type { AsyncThunk, SliceCaseReducers } from '@reduxjs/toolkit';
|
|
||||||
import { createAction, createSlice } from '@reduxjs/toolkit';
|
|
||||||
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
|
||||||
import type { ShlinkDomainRedirects } from '../../api/types';
|
|
||||||
import type { ProblemDetailsError } from '../../api/types/errors';
|
|
||||||
import { parseApiError } from '../../api/utils';
|
|
||||||
import { hasServerData } from '../../servers/data';
|
|
||||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
|
||||||
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
|
|
||||||
import type { Domain, DomainStatus } from '../data';
|
|
||||||
import type { EditDomainRedirects } from './domainRedirects';
|
|
||||||
|
|
||||||
const REDUCER_PREFIX = 'shlink/domainsList';
|
|
||||||
|
|
||||||
export interface DomainsList {
|
|
||||||
domains: Domain[];
|
|
||||||
filteredDomains: Domain[];
|
|
||||||
defaultRedirects?: ShlinkDomainRedirects;
|
|
||||||
loading: boolean;
|
|
||||||
error: boolean;
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ListDomains {
|
|
||||||
domains: Domain[];
|
|
||||||
defaultRedirects?: ShlinkDomainRedirects;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ValidateDomain {
|
|
||||||
domain: string;
|
|
||||||
status: DomainStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: DomainsList = {
|
|
||||||
domains: [],
|
|
||||||
filteredDomains: [],
|
|
||||||
loading: false,
|
|
||||||
error: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const replaceRedirectsOnDomain = ({ domain, redirects }: EditDomainRedirects) =>
|
|
||||||
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, redirects });
|
|
||||||
|
|
||||||
export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
|
|
||||||
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, status });
|
|
||||||
|
|
||||||
export const domainsListReducerCreator = (
|
|
||||||
buildShlinkApiClient: ShlinkApiClientBuilder,
|
|
||||||
editDomainRedirects: AsyncThunk<EditDomainRedirects, any, any>,
|
|
||||||
) => {
|
|
||||||
const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (_: void, { getState }): Promise<ListDomains> => {
|
|
||||||
const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState);
|
|
||||||
const { data, defaultRedirects } = await shlinkListDomains();
|
|
||||||
|
|
||||||
return {
|
|
||||||
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
|
|
||||||
defaultRedirects,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkDomainHealth = createAsyncThunk(
|
|
||||||
`${REDUCER_PREFIX}/checkDomainHealth`,
|
|
||||||
async (domain: string, { getState }): Promise<ValidateDomain> => {
|
|
||||||
const { selectedServer } = getState();
|
|
||||||
|
|
||||||
if (!hasServerData(selectedServer)) {
|
|
||||||
return { domain, status: 'invalid' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { url, ...rest } = selectedServer;
|
|
||||||
const { health } = buildShlinkApiClient({
|
|
||||||
...rest,
|
|
||||||
url: replaceAuthorityFromUri(url, domain),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { status } = await health();
|
|
||||||
|
|
||||||
return { domain, status: status === 'pass' ? 'valid' : 'invalid' };
|
|
||||||
} catch (e) {
|
|
||||||
return { domain, status: 'invalid' };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const filterDomains = createAction<string>(`${REDUCER_PREFIX}/filterDomains`);
|
|
||||||
|
|
||||||
const { reducer } = createSlice<DomainsList, SliceCaseReducers<DomainsList>>({
|
|
||||||
name: REDUCER_PREFIX,
|
|
||||||
initialState,
|
|
||||||
reducers: {},
|
|
||||||
extraReducers: (builder) => {
|
|
||||||
builder.addCase(listDomains.pending, () => ({ ...initialState, loading: true }));
|
|
||||||
builder.addCase(listDomains.rejected, (_, { error }) => (
|
|
||||||
{ ...initialState, error: true, errorData: parseApiError(error) }
|
|
||||||
));
|
|
||||||
builder.addCase(listDomains.fulfilled, (_, { payload }) => (
|
|
||||||
{ ...initialState, ...payload, filteredDomains: payload.domains }
|
|
||||||
));
|
|
||||||
|
|
||||||
builder.addCase(checkDomainHealth.fulfilled, ({ domains, filteredDomains, ...rest }, { payload }) => ({
|
|
||||||
...rest,
|
|
||||||
domains: domains.map(replaceStatusOnDomain(payload.domain, payload.status)),
|
|
||||||
filteredDomains: filteredDomains.map(replaceStatusOnDomain(payload.domain, payload.status)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
builder.addCase(filterDomains, (state, { payload }) => ({
|
|
||||||
...state,
|
|
||||||
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(payload.toLowerCase())),
|
|
||||||
}));
|
|
||||||
|
|
||||||
builder.addCase(editDomainRedirects.fulfilled, (state, { payload }) => ({
|
|
||||||
...state,
|
|
||||||
domains: state.domains.map(replaceRedirectsOnDomain(payload)),
|
|
||||||
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(payload)),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
reducer,
|
|
||||||
listDomains,
|
|
||||||
checkDomainHealth,
|
|
||||||
filterDomains,
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,34 +0,0 @@
|
||||||
import type Bottle from 'bottlejs';
|
|
||||||
import { prop } from 'ramda';
|
|
||||||
import type { ConnectDecorator } from '../../container/types';
|
|
||||||
import { DomainSelector } from '../DomainSelector';
|
|
||||||
import { ManageDomains } from '../ManageDomains';
|
|
||||||
import { editDomainRedirects } from '../reducers/domainRedirects';
|
|
||||||
import { domainsListReducerCreator } from '../reducers/domainsList';
|
|
||||||
|
|
||||||
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|
||||||
// Components
|
|
||||||
bottle.serviceFactory('DomainSelector', () => DomainSelector);
|
|
||||||
bottle.decorator('DomainSelector', connect(['domainsList'], ['listDomains']));
|
|
||||||
|
|
||||||
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
|
||||||
bottle.decorator('ManageDomains', connect(
|
|
||||||
['domainsList', 'selectedServer'],
|
|
||||||
['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'],
|
|
||||||
));
|
|
||||||
|
|
||||||
// Reducer
|
|
||||||
bottle.serviceFactory(
|
|
||||||
'domainsListReducerCreator',
|
|
||||||
domainsListReducerCreator,
|
|
||||||
'buildShlinkApiClient',
|
|
||||||
'editDomainRedirects',
|
|
||||||
);
|
|
||||||
bottle.serviceFactory('domainsListReducer', prop('reducer'), 'domainsListReducerCreator');
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
bottle.serviceFactory('listDomains', prop('listDomains'), 'domainsListReducerCreator');
|
|
||||||
bottle.serviceFactory('filterDomains', prop('filterDomains'), 'domainsListReducerCreator');
|
|
||||||
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
|
||||||
bottle.serviceFactory('checkDomainHealth', prop('checkDomainHealth'), 'domainsListReducerCreator');
|
|
||||||
};
|
|
247
src/index.scss
247
src/index.scss
|
@ -1,245 +1,4 @@
|
||||||
/* stylelint-disable no-descending-specificity */
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; // Before bootstrap stylesheet. Includes SASS var overrides
|
||||||
|
|
||||||
@import './utils/base';
|
|
||||||
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
||||||
@import './common/react-tag-autocomplete.scss';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/index'; // After bootstrap. Includes CSS overwrites
|
||||||
@import './utils/theme/theme';
|
@import 'node_modules/@shlinkio/shlink-web-component/dist/index';
|
||||||
@import './utils/mixins/text-ellipsis';
|
|
||||||
@import './utils/table/ResponsiveTable';
|
|
||||||
@import './utils/StickyCardPaginator';
|
|
||||||
|
|
||||||
* {
|
|
||||||
outline: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
scroll-behavior: auto;
|
|
||||||
color-scheme: var(--color-scheme);
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--secondary-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
a,
|
|
||||||
.btn-link {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* stylelint-disable-next-line selector-max-pseudo-class */
|
|
||||||
a:not(.nav-link):not(.navbar-brand):not(.page-link):not(.highlight-card):not(.btn):not(.dropdown-item):hover,
|
|
||||||
.btn-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-main {
|
|
||||||
background-color: $mainColor !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-warning {
|
|
||||||
color: $lightTextColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body,
|
|
||||||
.card-header,
|
|
||||||
.list-group-item {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-footer {
|
|
||||||
background-color: var(--primary-color-alfa);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
box-shadow: 0 .125rem .25rem rgb(0 0 0 / .075);
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
border-color: var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-group {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content,
|
|
||||||
.page-link,
|
|
||||||
.page-item.disabled .page-link,
|
|
||||||
.dropdown-menu {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header,
|
|
||||||
.modal-footer,
|
|
||||||
.card-header,
|
|
||||||
.card-footer,
|
|
||||||
.table thead th,
|
|
||||||
.table th,
|
|
||||||
.table td,
|
|
||||||
.page-link,
|
|
||||||
.page-link:hover,
|
|
||||||
.page-item.disabled .page-link,
|
|
||||||
.dropdown-divider,
|
|
||||||
.dropdown-menu,
|
|
||||||
.list-group-item,
|
|
||||||
.modal-content,
|
|
||||||
hr {
|
|
||||||
border-color: var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-bordered,
|
|
||||||
.table-bordered thead th,
|
|
||||||
.table-bordered thead td {
|
|
||||||
border-color: var(--table-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-link:hover,
|
|
||||||
.page-link:focus {
|
|
||||||
background-color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-item.active .page-link {
|
|
||||||
background-color: var(--brand-color);
|
|
||||||
border-color: var(--brand-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination .page-link {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container-xl {
|
|
||||||
@media (min-width: $xlgMin) {
|
|
||||||
max-width: 1320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: $smMax) {
|
|
||||||
padding-right: 0;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Deprecated. Brought from bootstrap 4 */
|
|
||||||
.btn-block {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary,
|
|
||||||
.btn-primary:hover,
|
|
||||||
.btn-primary:active,
|
|
||||||
.btn-primary.active,
|
|
||||||
.btn-outline-primary:hover,
|
|
||||||
.btn-outline-primary:active,
|
|
||||||
.btn-outline-primary.active, {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item,
|
|
||||||
.dropdown-item-text {
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item:not(:disabled) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item:focus:not(:disabled),
|
|
||||||
.dropdown-item:hover:not(:disabled),
|
|
||||||
.dropdown-item.active:not(:disabled),
|
|
||||||
.dropdown-item:active:not(:disabled) {
|
|
||||||
background-color: var(--active-color) !important;
|
|
||||||
color: var(--text-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item--danger.dropdown-item--danger {
|
|
||||||
color: $dangerColor;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:active,
|
|
||||||
&.active {
|
|
||||||
color: $dangerColor !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-main {
|
|
||||||
color: #ffffff;
|
|
||||||
background-color: var(--brand-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close,
|
|
||||||
.close:hover,
|
|
||||||
.table,
|
|
||||||
.table-hover > tbody > tr:hover > *,
|
|
||||||
.table-hover > tbody > tr > * {
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-close {
|
|
||||||
filter: var(--btn-close-filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-hover tbody tr:hover {
|
|
||||||
background-color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control,
|
|
||||||
.form-control:focus {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
border-color: var(--input-border-color);
|
|
||||||
color: var(--input-text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control.disabled,
|
|
||||||
.form-control:disabled {
|
|
||||||
background-color: var(--input-disabled-color);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card .form-control:not(:disabled),
|
|
||||||
.card .form-control:not(:disabled):hover {
|
|
||||||
background-color: var(--input-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-active,
|
|
||||||
.table-active > th,
|
|
||||||
.table-active > td {
|
|
||||||
background-color: var(--table-highlight-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
@media (max-width: $smMax) {
|
|
||||||
margin: 0 auto !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.indivisible {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pointer {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-ellipsis {
|
|
||||||
@include text-ellipsis();
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
background-color: $mainColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-xs-block {
|
|
||||||
@media (max-width: $xsMax) {
|
|
||||||
width: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-md-block {
|
|
||||||
@media (max-width: $mdMax) {
|
|
||||||
width: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import 'chart.js/auto'; // TODO Import specific ones to reduce bundle size https://react-chartjs-2.js.org/docs/migration-to-v4/#tree-shaking
|
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
@ -6,13 +5,7 @@ import pack from '../package.json';
|
||||||
import { container } from './container';
|
import { container } from './container';
|
||||||
import { setUpStore } from './container/store';
|
import { setUpStore } from './container/store';
|
||||||
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
||||||
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
import 'leaflet/dist/leaflet.css';
|
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
|
||||||
|
|
||||||
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
|
|
||||||
fixLeafletIcons();
|
|
||||||
|
|
||||||
const store = setUpStore(container);
|
const store = setUpStore(container);
|
||||||
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
|
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
export class Topics {
|
|
||||||
public static readonly visits = 'https://shlink.io/new-visit';
|
|
||||||
|
|
||||||
public static readonly orphanVisits = 'https://shlink.io/new-orphan-visit';
|
|
||||||
|
|
||||||
public static readonly shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`;
|
|
||||||
}
|
|
|
@ -1,45 +0,0 @@
|
||||||
import { pipe } from 'ramda';
|
|
||||||
import type { FC } from 'react';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import type { CreateVisit } from '../../visits/types';
|
|
||||||
import type { MercureInfo } from '../reducers/mercureInfo';
|
|
||||||
import { bindToMercureTopic } from './index';
|
|
||||||
|
|
||||||
export interface MercureBoundProps {
|
|
||||||
createNewVisits: (createdVisits: CreateVisit[]) => void;
|
|
||||||
loadMercureInfo: () => void;
|
|
||||||
mercureInfo: MercureInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function boundToMercureHub<T = {}>(
|
|
||||||
WrappedComponent: FC<MercureBoundProps & T>,
|
|
||||||
getTopicsForProps: (props: T, routeParams: any) => string[],
|
|
||||||
) {
|
|
||||||
const pendingUpdates = new Set<CreateVisit>();
|
|
||||||
|
|
||||||
return (props: MercureBoundProps & T) => {
|
|
||||||
const { createNewVisits, loadMercureInfo, mercureInfo } = props;
|
|
||||||
const { interval } = mercureInfo;
|
|
||||||
const params = useParams();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onMessage = (visit: CreateVisit) => (interval ? pendingUpdates.add(visit) : createNewVisits([visit]));
|
|
||||||
const topics = getTopicsForProps(props, params);
|
|
||||||
const closeEventSource = bindToMercureTopic(mercureInfo, topics, onMessage, loadMercureInfo);
|
|
||||||
|
|
||||||
if (!interval) {
|
|
||||||
return closeEventSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
createNewVisits([...pendingUpdates]);
|
|
||||||
pendingUpdates.clear();
|
|
||||||
}, interval * 1000 * 60);
|
|
||||||
|
|
||||||
return pipe(() => clearInterval(timer), () => closeEventSource?.());
|
|
||||||
}, [mercureInfo]);
|
|
||||||
|
|
||||||
return <WrappedComponent {...props} />;
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,31 +0,0 @@
|
||||||
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
|
|
||||||
import type { MercureInfo } from '../reducers/mercureInfo';
|
|
||||||
|
|
||||||
export const bindToMercureTopic = <T>(mercureInfo: MercureInfo, topics: string[], onMessage: (message: T) => void, onTokenExpired: () => void) => { // eslint-disable-line max-len
|
|
||||||
const { mercureHubUrl, token, loading, error } = mercureInfo;
|
|
||||||
|
|
||||||
if (loading || error || !mercureHubUrl) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onEventSourceMessage = ({ data }: { data: string }) => onMessage(JSON.parse(data) as T);
|
|
||||||
const onEventSourceError = ({ status }: { status: number }) => status === 401 && onTokenExpired();
|
|
||||||
|
|
||||||
const subscriptions = topics.map((topic) => {
|
|
||||||
const hubUrl = new URL(mercureHubUrl);
|
|
||||||
|
|
||||||
hubUrl.searchParams.append('topic', topic);
|
|
||||||
const es = new EventSource(hubUrl, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
es.onmessage = onEventSourceMessage;
|
|
||||||
es.onerror = onEventSourceError;
|
|
||||||
|
|
||||||
return es;
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => subscriptions.forEach((es) => es.close());
|
|
||||||
};
|
|
|
@ -1,44 +0,0 @@
|
||||||
import { createSlice } from '@reduxjs/toolkit';
|
|
||||||
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
|
||||||
import type { ShlinkMercureInfo } from '../../api/types';
|
|
||||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
|
||||||
|
|
||||||
const REDUCER_PREFIX = 'shlink/mercure';
|
|
||||||
|
|
||||||
export interface MercureInfo extends Partial<ShlinkMercureInfo> {
|
|
||||||
interval?: number;
|
|
||||||
loading: boolean;
|
|
||||||
error: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: MercureInfo = {
|
|
||||||
loading: true,
|
|
||||||
error: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const mercureInfoReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => {
|
|
||||||
const loadMercureInfo = createAsyncThunk(
|
|
||||||
`${REDUCER_PREFIX}/loadMercureInfo`,
|
|
||||||
(_: void, { getState }): Promise<ShlinkMercureInfo> => {
|
|
||||||
const { settings } = getState();
|
|
||||||
if (!settings.realTimeUpdates.enabled) {
|
|
||||||
throw new Error('Real time updates not enabled');
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildShlinkApiClient(getState).mercureInfo();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { reducer } = createSlice({
|
|
||||||
name: REDUCER_PREFIX,
|
|
||||||
initialState,
|
|
||||||
reducers: {},
|
|
||||||
extraReducers: (builder) => {
|
|
||||||
builder.addCase(loadMercureInfo.pending, (state) => ({ ...state, loading: true, error: false }));
|
|
||||||
builder.addCase(loadMercureInfo.rejected, (state) => ({ ...state, loading: false, error: true }));
|
|
||||||
builder.addCase(loadMercureInfo.fulfilled, (_, { payload }) => ({ ...payload, loading: false, error: false }));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { loadMercureInfo, reducer };
|
|
||||||
};
|
|
|
@ -1,12 +0,0 @@
|
||||||
import type Bottle from 'bottlejs';
|
|
||||||
import { prop } from 'ramda';
|
|
||||||
import { mercureInfoReducerCreator } from '../reducers/mercureInfo';
|
|
||||||
|
|
||||||
export const provideServices = (bottle: Bottle) => {
|
|
||||||
// Reducer
|
|
||||||
bottle.serviceFactory('mercureInfoReducerCreator', mercureInfoReducerCreator, 'buildShlinkApiClient');
|
|
||||||
bottle.serviceFactory('mercureInfoReducer', prop('reducer'), 'mercureInfoReducerCreator');
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
bottle.serviceFactory('loadMercureInfo', prop('loadMercureInfo'), 'mercureInfoReducerCreator');
|
|
||||||
};
|
|
|
@ -1,31 +1,12 @@
|
||||||
import { combineReducers } from '@reduxjs/toolkit';
|
import { combineReducers } from '@reduxjs/toolkit';
|
||||||
import type { IContainer } from 'bottlejs';
|
import type { IContainer } from 'bottlejs';
|
||||||
import { appUpdatesReducer } from '../app/reducers/appUpdates';
|
import { appUpdatesReducer } from '../app/reducers/appUpdates';
|
||||||
import { sidebarReducer } from '../common/reducers/sidebar';
|
|
||||||
import type { ShlinkState } from '../container/types';
|
|
||||||
import { serversReducer } from '../servers/reducers/servers';
|
import { serversReducer } from '../servers/reducers/servers';
|
||||||
import { settingsReducer } from '../settings/reducers/settings';
|
import { settingsReducer } from '../settings/reducers/settings';
|
||||||
|
|
||||||
export const initReducers = (container: IContainer) => combineReducers<ShlinkState>({
|
export const initReducers = (container: IContainer) => combineReducers({
|
||||||
|
appUpdated: appUpdatesReducer,
|
||||||
servers: serversReducer,
|
servers: serversReducer,
|
||||||
selectedServer: container.selectedServerReducer,
|
selectedServer: container.selectedServerReducer,
|
||||||
shortUrlsList: container.shortUrlsListReducer,
|
|
||||||
shortUrlCreation: container.shortUrlCreationReducer,
|
|
||||||
shortUrlDeletion: container.shortUrlDeletionReducer,
|
|
||||||
shortUrlEdition: container.shortUrlEditionReducer,
|
|
||||||
shortUrlDetail: container.shortUrlDetailReducer,
|
|
||||||
shortUrlVisits: container.shortUrlVisitsReducer,
|
|
||||||
tagVisits: container.tagVisitsReducer,
|
|
||||||
domainVisits: container.domainVisitsReducer,
|
|
||||||
orphanVisits: container.orphanVisitsReducer,
|
|
||||||
nonOrphanVisits: container.nonOrphanVisitsReducer,
|
|
||||||
tagsList: container.tagsListReducer,
|
|
||||||
tagDelete: container.tagDeleteReducer,
|
|
||||||
tagEdit: container.tagEditReducer,
|
|
||||||
mercureInfo: container.mercureInfoReducer,
|
|
||||||
settings: settingsReducer,
|
settings: settingsReducer,
|
||||||
domainsList: container.domainsListReducer,
|
|
||||||
visitsOverview: container.visitsOverviewReducer,
|
|
||||||
appUpdated: appUpdatesReducer,
|
|
||||||
sidebar: sidebarReducer,
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
|
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import { Result, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from 'reactstrap';
|
import { Button } from 'reactstrap';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import type { TimeoutToggle } from '../utils/helpers/hooks';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
import { useGoBack, useToggle } from '../utils/helpers/hooks';
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import { Result } from '../utils/Result';
|
import { useGoBack } from '../utils/helpers/hooks';
|
||||||
import type { ServerData, ServersMap, ServerWithId } from './data';
|
import type { ServerData, ServersMap, ServerWithId } from './data';
|
||||||
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
||||||
import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||||
|
@ -14,10 +16,15 @@ import { ServerForm } from './helpers/ServerForm';
|
||||||
|
|
||||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||||
|
|
||||||
interface CreateServerProps {
|
type CreateServerProps = {
|
||||||
createServers: (servers: ServerWithId[]) => void;
|
createServers: (servers: ServerWithId[]) => void;
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
type CreateServerDeps = {
|
||||||
|
ImportServersBtn: FC<ImportServersBtnProps>;
|
||||||
|
useTimeoutToggle: TimeoutToggle;
|
||||||
|
};
|
||||||
|
|
||||||
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
|
@ -28,34 +35,33 @@ const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useTimeoutToggle: TimeoutToggle) => (
|
const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers, createServers }) => {
|
||||||
{ servers, createServers }: CreateServerProps,
|
const { ImportServersBtn, useTimeoutToggle } = useDependencies(CreateServer);
|
||||||
) => {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
const hasServers = !!Object.keys(servers).length;
|
const hasServers = !!Object.keys(servers).length;
|
||||||
const [serversImported, setServersImported] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
const [serversImported, setServersImported] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||||
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||||
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
|
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
|
||||||
const [serverData, setServerData] = useState<ServerData | undefined>();
|
const [serverData, setServerData] = useState<ServerData>();
|
||||||
const save = () => {
|
const saveNewServer = useCallback((theServerData: ServerData) => {
|
||||||
|
const id = uuid();
|
||||||
|
|
||||||
|
createServers([{ ...theServerData, id }]);
|
||||||
|
navigate(`/server/${id}`);
|
||||||
|
}, [createServers, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (!serverData) {
|
if (!serverData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = uuid();
|
|
||||||
|
|
||||||
createServers([{ ...serverData, id }]);
|
|
||||||
navigate(`/server/${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const serverExists = Object.values(servers).some(
|
const serverExists = Object.values(servers).some(
|
||||||
({ url, apiKey }) => serverData?.url === url && serverData?.apiKey === apiKey,
|
({ url, apiKey }) => serverData?.url === url && serverData?.apiKey === apiKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
serverExists ? toggleConfirmModal() : save();
|
serverExists ? toggleConfirmModal() : saveNewServer(serverData);
|
||||||
}, [serverData]);
|
}, [saveNewServer, serverData, servers, toggleConfirmModal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
|
@ -74,8 +80,10 @@ export const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useTim
|
||||||
isOpen={isConfirmModalOpen}
|
isOpen={isConfirmModalOpen}
|
||||||
duplicatedServers={serverData ? [serverData] : []}
|
duplicatedServers={serverData ? [serverData] : []}
|
||||||
onDiscard={goBack}
|
onDiscard={goBack}
|
||||||
onSave={save}
|
onSave={() => serverData && saveNewServer(serverData)}
|
||||||
/>
|
/>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CreateServerFactory = componentFactory(CreateServer, ['ImportServersBtn', 'useTimeoutToggle']);
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
import type { FC, PropsWithChildren } from 'react';
|
import type { FC, PropsWithChildren } from 'react';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import type { ServerWithId } from './data';
|
import type { ServerWithId } from './data';
|
||||||
import type { DeleteServerModalProps } from './DeleteServerModal';
|
import type { DeleteServerModalProps } from './DeleteServerModal';
|
||||||
|
|
||||||
|
@ -11,19 +14,26 @@ export type DeleteServerButtonProps = PropsWithChildren<{
|
||||||
textClassName?: string;
|
textClassName?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<DeleteServerButtonProps> => (
|
type DeleteServerButtonDeps = {
|
||||||
|
DeleteServerModal: FC<DeleteServerModalProps>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButtonDeps> = (
|
||||||
{ server, className, children, textClassName },
|
{ server, className, children, textClassName },
|
||||||
) => {
|
) => {
|
||||||
|
const { DeleteServerModal } = useDependencies(DeleteServerButton);
|
||||||
const [isModalOpen, , showModal, hideModal] = useToggle();
|
const [isModalOpen, , showModal, hideModal] = useToggle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className={className} onClick={showModal}>
|
<button type="button" className={clsx(className, 'p-0 bg-transparent border-0')} onClick={showModal}>
|
||||||
{!children && <FontAwesomeIcon fixedWidth icon={deleteIcon} />}
|
{!children && <FontAwesomeIcon fixedWidth icon={deleteIcon} />}
|
||||||
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
||||||
</span>
|
</button>
|
||||||
|
|
||||||
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
|
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DeleteServerButtonFactory = componentFactory(DeleteServerButton, ['DeleteServerModal']);
|
||||||
|
|
|
@ -1,17 +1,24 @@
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Button } from 'reactstrap';
|
import { Button } from 'reactstrap';
|
||||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
|
import type { FCWithDeps } from '../container/utils';
|
||||||
|
import { componentFactory } from '../container/utils';
|
||||||
import { useGoBack, useParsedQuery } from '../utils/helpers/hooks';
|
import { useGoBack, useParsedQuery } from '../utils/helpers/hooks';
|
||||||
import type { ServerData } from './data';
|
import type { ServerData } from './data';
|
||||||
import { isServerWithId } from './data';
|
import { isServerWithId } from './data';
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
|
import type { WithSelectedServerProps } from './helpers/withSelectedServer';
|
||||||
import { withSelectedServer } from './helpers/withSelectedServer';
|
import { withSelectedServer } from './helpers/withSelectedServer';
|
||||||
|
|
||||||
interface EditServerProps {
|
type EditServerProps = WithSelectedServerProps & {
|
||||||
editServer: (serverId: string, serverData: ServerData) => void;
|
editServer: (serverId: string, serverData: ServerData) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
|
type EditServerDeps = {
|
||||||
|
ServerError: FC;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditServer: FCWithDeps<EditServerProps, EditServerDeps> = withSelectedServer((
|
||||||
{ editServer, selectedServer, selectServer },
|
{ editServer, selectedServer, selectServer },
|
||||||
) => {
|
) => {
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
|
@ -39,4 +46,6 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
|
||||||
</ServerForm>
|
</ServerForm>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
}, ServerError);
|
});
|
||||||
|
|
||||||
|
export const EditServerFactory = componentFactory(EditServer, ['ServerError']);
|
||||||
|
|
|
@ -1,31 +1,39 @@
|
||||||
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import { Result, SearchField, SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Button, Row } from 'reactstrap';
|
import { Button, Row } from 'reactstrap';
|
||||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import type { TimeoutToggle } from '../utils/helpers/hooks';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
import { Result } from '../utils/Result';
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import { SearchField } from '../utils/SearchField';
|
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
|
||||||
import type { ServersMap } from './data';
|
import type { ServersMap } from './data';
|
||||||
import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||||
import type { ManageServersRowProps } from './ManageServersRow';
|
import type { ManageServersRowProps } from './ManageServersRow';
|
||||||
import type { ServersExporter } from './services/ServersExporter';
|
import type { ServersExporter } from './services/ServersExporter';
|
||||||
|
|
||||||
interface ManageServersProps {
|
type ManageServersProps = {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
type ManageServersDeps = {
|
||||||
|
ServersExporter: ServersExporter;
|
||||||
|
ImportServersBtn: FC<ImportServersBtnProps>;
|
||||||
|
useTimeoutToggle: TimeoutToggle;
|
||||||
|
ManageServersRow: FC<ManageServersRowProps>;
|
||||||
|
};
|
||||||
|
|
||||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||||
|
|
||||||
export const ManageServers = (
|
const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ servers }) => {
|
||||||
serversExporter: ServersExporter,
|
const {
|
||||||
ImportServersBtn: FC<ImportServersBtnProps>,
|
ServersExporter: serversExporter,
|
||||||
useTimeoutToggle: TimeoutToggle,
|
ImportServersBtn,
|
||||||
ManageServersRow: FC<ManageServersRowProps>,
|
useTimeoutToggle,
|
||||||
): FC<ManageServersProps> => ({ servers }) => {
|
ManageServersRow,
|
||||||
|
} = useDependencies(ManageServers);
|
||||||
const allServers = Object.values(servers);
|
const allServers = Object.values(servers);
|
||||||
const [serversList, setServersList] = useState(allServers);
|
const [serversList, setServersList] = useState(allServers);
|
||||||
const filterServers = (searchTerm: string) => setServersList(
|
const filterServers = (searchTerm: string) => setServersList(
|
||||||
|
@ -62,10 +70,10 @@ export const ManageServers = (
|
||||||
<table className="table table-hover responsive-table mb-0">
|
<table className="table table-hover responsive-table mb-0">
|
||||||
<thead className="responsive-table__header">
|
<thead className="responsive-table__header">
|
||||||
<tr>
|
<tr>
|
||||||
{hasAutoConnect && <th aria-label="Auto-connect" style={{ width: '50px' }} />}
|
{hasAutoConnect && <th style={{ width: '50px' }}><span className="sr-only">Auto-connect</span></th>}
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Base URL</th>
|
<th>Base URL</th>
|
||||||
<th aria-label="Options" />
|
<th><span className="sr-only">Options</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -85,3 +93,10 @@ export const ManageServers = (
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ManageServersFactory = componentFactory(ManageServers, [
|
||||||
|
'ServersExporter',
|
||||||
|
'ImportServersBtn',
|
||||||
|
'useTimeoutToggle',
|
||||||
|
'ManageServersRow',
|
||||||
|
]);
|
||||||
|
|
|
@ -3,36 +3,46 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import type { FCWithDeps } from '../container/utils';
|
||||||
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import type { ServerWithId } from './data';
|
import type { ServerWithId } from './data';
|
||||||
import type { ManageServersRowDropdownProps } from './ManageServersRowDropdown';
|
import type { ManageServersRowDropdownProps } from './ManageServersRowDropdown';
|
||||||
|
|
||||||
export interface ManageServersRowProps {
|
export type ManageServersRowProps = {
|
||||||
server: ServerWithId;
|
server: ServerWithId;
|
||||||
hasAutoConnect: boolean;
|
hasAutoConnect: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const ManageServersRow = (
|
type ManageServersRowDeps = {
|
||||||
ManageServersRowDropdown: FC<ManageServersRowDropdownProps>,
|
ManageServersRowDropdown: FC<ManageServersRowDropdownProps>;
|
||||||
): FC<ManageServersRowProps> => ({ server, hasAutoConnect }) => (
|
};
|
||||||
<tr className="responsive-table__row">
|
|
||||||
{hasAutoConnect && (
|
const ManageServersRow: FCWithDeps<ManageServersRowProps, ManageServersRowDeps> = ({ server, hasAutoConnect }) => {
|
||||||
<td className="responsive-table__cell" data-th="Auto-connect">
|
const { ManageServersRowDropdown } = useDependencies(ManageServersRow);
|
||||||
{server.autoConnect && (
|
|
||||||
<>
|
return (
|
||||||
<FontAwesomeIcon icon={checkIcon} className="text-primary" id="autoConnectIcon" />
|
<tr className="responsive-table__row">
|
||||||
<UncontrolledTooltip target="autoConnectIcon" placement="right">
|
{hasAutoConnect && (
|
||||||
Auto-connect to this server
|
<td className="responsive-table__cell" data-th="Auto-connect">
|
||||||
</UncontrolledTooltip>
|
{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-end">
|
||||||
|
<ManageServersRowDropdown server={server} />
|
||||||
</td>
|
</td>
|
||||||
)}
|
</tr>
|
||||||
<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>
|
export const ManageServersRowFactory = componentFactory(ManageServersRow, ['ManageServersRowDropdown']);
|
||||||
<td className="responsive-table__cell text-end">
|
|
||||||
<ManageServersRowDropdown server={server} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
|
|
|
@ -6,25 +6,31 @@ import {
|
||||||
faPlug as connectIcon,
|
faPlug as connectIcon,
|
||||||
} 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 { RowDropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { DropdownItem } from 'reactstrap';
|
import { DropdownItem } from 'reactstrap';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
import { RowDropdownBtn } from '../utils/RowDropdownBtn';
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import type { ServerWithId } from './data';
|
import type { ServerWithId } from './data';
|
||||||
import type { DeleteServerModalProps } from './DeleteServerModal';
|
import type { DeleteServerModalProps } from './DeleteServerModal';
|
||||||
|
|
||||||
export interface ManageServersRowDropdownProps {
|
export type ManageServersRowDropdownProps = {
|
||||||
server: ServerWithId;
|
server: ServerWithId;
|
||||||
}
|
};
|
||||||
|
|
||||||
interface ManageServersRowDropdownConnectProps extends ManageServersRowDropdownProps {
|
type ManageServersRowDropdownConnectProps = ManageServersRowDropdownProps & {
|
||||||
setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void;
|
setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const ManageServersRowDropdown = (
|
type ManageServersRowDropdownDeps = {
|
||||||
DeleteServerModal: FC<DeleteServerModalProps>,
|
DeleteServerModal: FC<DeleteServerModalProps>
|
||||||
): FC<ManageServersRowDropdownConnectProps> => ({ server, setAutoConnect }) => {
|
};
|
||||||
|
|
||||||
|
const ManageServersRowDropdown: FCWithDeps<ManageServersRowDropdownConnectProps, ManageServersRowDropdownDeps> = (
|
||||||
|
{ server, setAutoConnect },
|
||||||
|
) => {
|
||||||
|
const { DeleteServerModal } = useDependencies(ManageServersRowDropdown);
|
||||||
const [isModalOpen,, showModal, hideModal] = useToggle();
|
const [isModalOpen,, showModal, hideModal] = useToggle();
|
||||||
const serverUrl = `/server/${server.id}`;
|
const serverUrl = `/server/${server.id}`;
|
||||||
const { autoConnect: isAutoConnect } = server;
|
const { autoConnect: isAutoConnect } = server;
|
||||||
|
@ -41,7 +47,7 @@ export const ManageServersRowDropdown = (
|
||||||
<DropdownItem onClick={() => setAutoConnect(server, !isAutoConnect)}>
|
<DropdownItem onClick={() => setAutoConnect(server, !isAutoConnect)}>
|
||||||
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
|
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem divider />
|
<DropdownItem divider tag="hr" />
|
||||||
<DropdownItem className="dropdown-item--danger" onClick={showModal}>
|
<DropdownItem className="dropdown-item--danger" onClick={showModal}>
|
||||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
|
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
@ -50,3 +56,5 @@ export const ManageServersRowDropdown = (
|
||||||
</RowDropdownBtn>
|
</RowDropdownBtn>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ManageServersRowDropdownFactory = componentFactory(ManageServersRowDropdown, ['DeleteServerModal']);
|
||||||
|
|
|
@ -1,119 +0,0 @@
|
||||||
import type { FC } from 'react';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import { Card, CardBody, CardHeader, Row } from 'reactstrap';
|
|
||||||
import type { ShlinkShortUrlsListParams } from '../api/types';
|
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
|
||||||
import type { Settings } from '../settings/reducers/settings';
|
|
||||||
import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
|
||||||
import type { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
|
||||||
import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList';
|
|
||||||
import type { ShortUrlsTableType } from '../short-urls/ShortUrlsTable';
|
|
||||||
import type { TagsList } from '../tags/reducers/tagsList';
|
|
||||||
import { useFeature } from '../utils/helpers/features';
|
|
||||||
import { prettify } from '../utils/helpers/numbers';
|
|
||||||
import type { VisitsOverview } from '../visits/reducers/visitsOverview';
|
|
||||||
import type { SelectedServer } from './data';
|
|
||||||
import { getServerId } from './data';
|
|
||||||
import { HighlightCard } from './helpers/HighlightCard';
|
|
||||||
import { VisitsHighlightCard } from './helpers/VisitsHighlightCard';
|
|
||||||
|
|
||||||
interface OverviewConnectProps {
|
|
||||||
shortUrlsList: ShortUrlsListState;
|
|
||||||
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
|
||||||
listTags: Function;
|
|
||||||
tagsList: TagsList;
|
|
||||||
selectedServer: SelectedServer;
|
|
||||||
visitsOverview: VisitsOverview;
|
|
||||||
loadVisitsOverview: Function;
|
|
||||||
settings: Settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Overview = (
|
|
||||||
ShortUrlsTable: ShortUrlsTableType,
|
|
||||||
CreateShortUrl: FC<CreateShortUrlProps>,
|
|
||||||
) => boundToMercureHub(({
|
|
||||||
shortUrlsList,
|
|
||||||
listShortUrls,
|
|
||||||
listTags,
|
|
||||||
tagsList,
|
|
||||||
selectedServer,
|
|
||||||
loadVisitsOverview,
|
|
||||||
visitsOverview,
|
|
||||||
settings: { visits },
|
|
||||||
}: OverviewConnectProps) => {
|
|
||||||
const { loading, shortUrls } = shortUrlsList;
|
|
||||||
const { loading: loadingTags } = tagsList;
|
|
||||||
const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview;
|
|
||||||
const serverId = getServerId(selectedServer);
|
|
||||||
const linkToNonOrphanVisits = useFeature('nonOrphanVisits', selectedServer);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } });
|
|
||||||
listTags();
|
|
||||||
loadVisitsOverview();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Row>
|
|
||||||
<div className="col-lg-6 col-xl-3 mb-3">
|
|
||||||
<VisitsHighlightCard
|
|
||||||
title="Visits"
|
|
||||||
link={linkToNonOrphanVisits ? `/server/${serverId}/non-orphan-visits` : undefined}
|
|
||||||
excludeBots={visits?.excludeBots ?? false}
|
|
||||||
loading={loadingVisits}
|
|
||||||
visitsSummary={nonOrphanVisits}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-lg-6 col-xl-3 mb-3">
|
|
||||||
<VisitsHighlightCard
|
|
||||||
title="Orphan visits"
|
|
||||||
link={`/server/${serverId}/orphan-visits`}
|
|
||||||
excludeBots={visits?.excludeBots ?? false}
|
|
||||||
loading={loadingVisits}
|
|
||||||
visitsSummary={orphanVisits}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-lg-6 col-xl-3 mb-3">
|
|
||||||
<HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}>
|
|
||||||
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
|
|
||||||
</HighlightCard>
|
|
||||||
</div>
|
|
||||||
<div className="col-lg-6 col-xl-3 mb-3">
|
|
||||||
<HighlightCard title="Tags" link={`/server/${serverId}/manage-tags`}>
|
|
||||||
{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}
|
|
||||||
</HighlightCard>
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Card className="mb-3">
|
|
||||||
<CardHeader>
|
|
||||||
<span className="d-sm-none">Create a short URL</span>
|
|
||||||
<h5 className="d-none d-sm-inline">Create a short URL</h5>
|
|
||||||
<Link className="float-end" to={`/server/${serverId}/create-short-url`}>Advanced options »</Link>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<CreateShortUrl basicMode />
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<span className="d-sm-none">Recently created URLs</span>
|
|
||||||
<h5 className="d-none d-sm-inline">Recently created URLs</h5>
|
|
||||||
<Link className="float-end" to={`/server/${serverId}/list-short-urls/1`}>See all »</Link>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<ShortUrlsTable
|
|
||||||
shortUrlsList={shortUrlsList}
|
|
||||||
selectedServer={selectedServer}
|
|
||||||
className="mb-0"
|
|
||||||
onTagClick={(tag) => navigate(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
|
|
||||||
/>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}, () => [Topics.visits, Topics.orphanVisits]);
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { faPlus as plusIcon, 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 { isEmpty, values } from 'ramda';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||||
import type { SelectedServer, ServersMap } from './data';
|
import type { SelectedServer, ServersMap } from './data';
|
||||||
|
@ -12,10 +11,10 @@ export interface ServersDropdownProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
|
export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||||
const serversList = values(servers);
|
const serversList = Object.values(servers);
|
||||||
|
|
||||||
const renderServers = () => {
|
const renderServers = () => {
|
||||||
if (isEmpty(serversList)) {
|
if (serversList.length === 0) {
|
||||||
return (
|
return (
|
||||||
<DropdownItem tag={Link} to="/server/create">
|
<DropdownItem tag={Link} to="/server/create">
|
||||||
<FontAwesomeIcon icon={plusIcon} /> <span className="ms-1">Add a server</span>
|
<FontAwesomeIcon icon={plusIcon} /> <span className="ms-1">Add a server</span>
|
||||||
|
@ -30,7 +29,7 @@ export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProp
|
||||||
{name}
|
{name}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
))}
|
))}
|
||||||
<DropdownItem divider />
|
<DropdownItem divider tag="hr" />
|
||||||
<DropdownItem tag={Link} to="/manage-servers">
|
<DropdownItem tag={Link} to="/manage-servers">
|
||||||
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Manage servers</span>
|
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Manage servers</span>
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../utils/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
@import '../utils/mixins/vertical-align';
|
@import '../utils/mixins/vertical-align';
|
||||||
@import '../utils/mixins/thin-scroll';
|
@import '../utils/mixins/thin-scroll';
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import classNames from 'classnames';
|
import { clsx } from 'clsx';
|
||||||
import type { FC, PropsWithChildren } from 'react';
|
import type { FC, PropsWithChildren } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ListGroup, ListGroupItem } from 'reactstrap';
|
import { ListGroup, ListGroupItem } from 'reactstrap';
|
||||||
|
@ -21,10 +21,12 @@ const ServerListItem = ({ id, name }: { id: string; name: string }) => (
|
||||||
|
|
||||||
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, children, embedded = false }) => (
|
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, children, embedded = false }) => (
|
||||||
<>
|
<>
|
||||||
{children && <h5 className="mb-md-3">{children}</h5>}
|
{children && <div data-testid="title" className="mb-0 fs-5 fw-normal lh-sm">{children}</div>}
|
||||||
{servers.length > 0 && (
|
{servers.length > 0 && (
|
||||||
<ListGroup
|
<ListGroup
|
||||||
className={classNames('servers-list__list-group', { 'servers-list__list-group--embedded': embedded })}
|
data-testid="list"
|
||||||
|
tag="div"
|
||||||
|
className={clsx('servers-list__list-group', { 'servers-list__list-group--embedded': embedded })}
|
||||||
>
|
>
|
||||||
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
|
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { omit } from 'ramda';
|
|
||||||
import type { SemVer } from '../../utils/helpers/version';
|
import type { SemVer } from '../../utils/helpers/version';
|
||||||
|
|
||||||
export interface ServerData {
|
export interface ServerData {
|
||||||
|
@ -45,5 +44,4 @@ export const isNotFoundServer = (server: SelectedServer): server is NotFoundServ
|
||||||
|
|
||||||
export const getServerId = (server: SelectedServer) => (isServerWithId(server) ? server.id : '');
|
export const getServerId = (server: SelectedServer) => (isServerWithId(server) ? server.id : '');
|
||||||
|
|
||||||
export const serverWithIdToServerData = (server: ServerWithId): ServerData =>
|
export const serverWithIdToServerData = ({ id, autoConnect, ...server }: ServerWithId): ServerData => server;
|
||||||
omit<ServerWithId, 'id' | 'autoConnect'>(['id', 'autoConnect'], server);
|
|
||||||
|
|
|
@ -33,7 +33,7 @@ export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
|
||||||
</span>
|
</span>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button color="link" onClick={onDiscard}>{hasMultipleServers ? 'Ignore duplicated' : 'Discard'}</Button>
|
<Button color="link" onClick={onDiscard}>{hasMultipleServers ? 'Ignore duplicates' : 'Discard'}</Button>
|
||||||
<Button color="primary" onClick={onSave}>Save anyway</Button>
|
<Button color="primary" onClick={onSave}>Save anyway</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
@import '../../utils/base';
|
|
||||||
|
|
||||||
.highlight-card.highlight-card {
|
|
||||||
text-align: center;
|
|
||||||
border-top: 3px solid var(--brand-color);
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlight-card__link-icon {
|
|
||||||
position: absolute;
|
|
||||||
right: 5px;
|
|
||||||
bottom: 5px;
|
|
||||||
opacity: 0.1;
|
|
||||||
transform: rotate(-45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlight-card__title {
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: $textPlaceholder;
|
|
||||||
}
|
|
|
@ -1,30 +0,0 @@
|
||||||
import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { Card, CardText, CardTitle, UncontrolledTooltip } from 'reactstrap';
|
|
||||||
import { useElementRef } from '../../utils/helpers/hooks';
|
|
||||||
import './HighlightCard.scss';
|
|
||||||
|
|
||||||
export type HighlightCardProps = PropsWithChildren<{
|
|
||||||
title: string;
|
|
||||||
link?: string;
|
|
||||||
tooltip?: ReactNode;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const buildExtraProps = (link?: string) => (!link ? {} : { tag: Link, to: link });
|
|
||||||
|
|
||||||
export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link, tooltip }) => {
|
|
||||||
const ref = useElementRef<HTMLElement>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Card innerRef={ref} className="highlight-card" body {...buildExtraProps(link)}>
|
|
||||||
{link && <FontAwesomeIcon size="3x" className="highlight-card__link-icon" icon={linkIcon} />}
|
|
||||||
<CardTitle tag="h5" className="highlight-card__title">{title}</CardTitle>
|
|
||||||
<CardText tag="h2">{children}</CardText>
|
|
||||||
</Card>
|
|
||||||
{tooltip && <UncontrolledTooltip target={ref} placement="bottom">{tooltip}</UncontrolledTooltip>}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,5 +0,0 @@
|
||||||
.import-servers-btn__csv-select {
|
|
||||||
position: absolute;
|
|
||||||
left: -9999px;
|
|
||||||
top: -9999px;
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue