Compare commits

...

62 Commits

Author SHA1 Message Date
Alejandro Celaya
457458a894 Merge pull request #820 from shlinkio/develop
Release 3.10.0
2023-03-19 12:00:45 +01:00
Alejandro Celaya
f6334c3618 Merge pull request #819 from acelaya-forks/feature/tags-non-bot-visits
Feature/tags non bot visits
2023-03-19 11:54:54 +01:00
Alejandro Celaya
cf27de965e Remove redundant type 2023-03-19 11:50:06 +01:00
Alejandro Celaya
43b2926063 Update changelog 2023-03-19 11:47:02 +01:00
Alejandro Celaya
1d6464fefb Take into consideration types of visits when increasing tags visits 2023-03-19 11:44:40 +01:00
Alejandro Celaya
927ab76dbd Increase required coverage 2023-03-19 10:46:52 +01:00
Alejandro Celaya
34cfe2077b Test proper amount of visits is displayed in TagsList 2023-03-19 10:44:09 +01:00
Alejandro Celaya
4ebe23e89f Add logic to dynamically exclude bots visits in tags table 2023-03-19 10:32:11 +01:00
Alejandro Celaya
8fa61a6301 Merge pull request #817 from acelaya-forks/feature/tags-stats
Feature/tags stats
2023-03-18 16:36:06 +01:00
Alejandro Celaya
96c20b36a5 Split tagsList and tagsStats methods in ShlinkApiClient for clarity 2023-03-18 16:32:04 +01:00
Alejandro Celaya
a9af5163c1 Update changelog 2023-03-18 16:27:52 +01:00
Alejandro Celaya
b87b108e53 Use /tags/stats endpoint when the server supports it 2023-03-18 16:26:28 +01:00
Alejandro Celaya
ddaec7c6ac Merge pull request #816 from acelaya-forks/feature/decouple-actions
Feature/decouple actions
2023-03-18 16:07:15 +01:00
Alejandro Celaya
4e8e16f16d Refactor of redux tests to avoid covering RTK implementation details 2023-03-18 16:02:06 +01:00
Alejandro Celaya
9cefdb7977 First refactor of redux tests to avoid covering RTK implementation details 2023-03-18 12:09:38 +01:00
Alejandro Celaya
a6d000714b Merge pull request #815 from acelaya-forks/feature/overview-bots
Feature/overview bots
2023-03-18 11:15:01 +01:00
Alejandro Celaya
54758272be Update changelog 2023-03-18 11:10:50 +01:00
Alejandro Celaya
8e9e2c5b61 Create test for VisitsHighlightCard 2023-03-18 11:10:03 +01:00
Alejandro Celaya
934bf495a0 Extend overview to exclude/include bot visits based on config 2023-03-18 10:55:07 +01:00
Alejandro Celaya
1d8189369c Enhance visits overview reducer to handle bot and non-bots visits amounts 2023-03-18 10:54:14 +01:00
Alejandro Celaya
25aa9b9bd7 Enhance types including potential bots on visits summary endpoint 2023-03-18 10:29:49 +01:00
Alejandro Celaya
a1b879a5b4 Add support for a tooltip on HighlightCard component 2023-03-18 10:17:17 +01:00
Alejandro Celaya
b70724f7d6 Update babel-typescript plugin 2023-03-18 10:08:44 +01:00
Alejandro Celaya
a52f96f8e5 Merge pull request #814 from acelaya-forks/feature/update-ts-vite
Update to latest TypeScript and Vite versions
2023-03-17 08:57:11 +01:00
Alejandro Celaya
970c573a12 Update to latest TypeScript and Vite versions 2023-03-17 08:49:47 +01:00
Alejandro Celaya
46749044e2 Merge pull request #813 from acelaya-forks/feature/device-long-urls
Feature/device long urls
2023-03-14 09:11:54 +01:00
Alejandro Celaya
16d748800c Update copy-to-clipboard icons 2023-03-14 09:06:57 +01:00
Alejandro Celaya
3e698b045a Add IconInput test 2023-03-14 09:02:12 +01:00
Alejandro Celaya
999b21577a Removed duplicated CSS from DateInput 2023-03-14 08:50:53 +01:00
Alejandro Celaya
3be5126e2d Add missing ref to IconInput 2023-03-13 18:18:35 +01:00
Alejandro Celaya
2b14c49c80 Update snapshots 2023-03-13 18:04:09 +01:00
Alejandro Celaya
bace2a10e8 Create component to display input with an icon 2023-03-13 18:02:29 +01:00
Alejandro Celaya
006e6b30b7 Update changelog 2023-03-13 09:06:49 +01:00
Alejandro Celaya
4c5d0321d2 Add support for device-specific long URLs when using Shlink 3.5.0 or newer 2023-03-13 09:05:54 +01:00
Alejandro Celaya
fa69c21fa2 Merge pull request #811 from acelaya-forks/feature/feature-flag-hooks
Convert feature flags into hooks
2023-03-11 10:38:26 +01:00
Alejandro Celaya
95439e5602 Convert feature flags into hooks 2023-03-11 10:33:03 +01:00
Alejandro Celaya
bbd8d8ef4e Fix border radius on tags input 2023-03-10 09:17:07 +01:00
Alejandro Celaya
ef269d565c Merge pull request #810 from acelaya-forks/feature/fallback-bots
Make sure the request to get the latest fallback visit respects bots config
2023-03-10 08:58:56 +01:00
Alejandro Celaya
8acf6dda6e Make sure the request to get the latest fallback visit respects bots config 2023-03-10 08:53:05 +01:00
Alejandro Celaya
d18219dc14 Merge pull request #806 from acelaya-forks/feature/preview
Feature/preview
2023-03-08 09:27:57 +01:00
Alejandro Celaya
3f1718f4c5 Fix merge conflicts 2023-03-08 09:21:05 +01:00
Alejandro Celaya
825a749b45 Replace serve with vite preview to check generated build 2023-03-08 09:18:32 +01:00
Alejandro Celaya
c2eb09e664 Merge pull request #804 from acelaya-forks/feature/update-coding-standard
Feature/update coding standard
2023-02-18 11:44:05 +01:00
Alejandro Celaya
adb670dd0c Update coding standard 2023-02-18 11:40:04 +01:00
Alejandro Celaya
5e9ec071dc Remove default exports 2023-02-18 11:37:49 +01:00
Alejandro Celaya
1f41f8da23 Ordered imports alphabetically 2023-02-18 11:15:35 +01:00
Alejandro Celaya
2a5480da79 Add import type whenever possible 2023-02-18 10:40:37 +01:00
Alejandro Celaya
7add854b40 Merge pull request #803 from acelaya-forks/feature/remove-stryker
Remove stryker and mutation testing
2023-02-17 20:31:39 +01:00
Alejandro Celaya
e639cd0bd2 Update changelog 2023-02-17 20:27:48 +01:00
Alejandro Celaya
3503f1f580 Remove stryker and mutation testing 2023-02-17 20:09:31 +01:00
Alejandro Celaya
853dcbd69a Merge pull request #801 from acelaya-forks/feature/vite-4.1
Update to vite 4.1
2023-02-11 13:22:56 +01:00
Alejandro Celaya
c54fff5472 Update to vite 4.1 2023-02-11 13:16:41 +01:00
Alejandro Celaya
699d3d3eaa Fix twitter badge 2023-01-28 11:18:08 +01:00
Alejandro Celaya
0c91f488f0 Merge pull request #794 from acelaya-forks/feature/domain
Replace references to doma.in with s.test
2023-01-18 08:12:25 +01:00
Alejandro Celaya
d3a644877e Replace references to doma.in with s.test 2023-01-17 22:53:49 +01:00
Alejandro Celaya
aac2832eb7 Merge pull request #791 from acelaya-forks/feature/fix-ref-types
Improved types on element ref objects and their usage
2023-01-10 20:11:36 +01:00
Alejandro Celaya
487c832f5b Improved types on element ref objects and their usage 2023-01-10 20:04:47 +01:00
Alejandro Celaya
98e2e57bb2 Merge pull request #790 from shlinkio/dependabot/npm_and_yarn/json5-1.0.2
Bump json5 from 1.0.1 to 1.0.2
2023-01-08 09:04:30 +01:00
dependabot[bot]
c5170df402 Bump json5 from 1.0.1 to 1.0.2
Bumps [json5](https://github.com/json5/json5) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v1.0.1...v1.0.2)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-08 06:27:50 +00:00
Alejandro Celaya
4be38dfd0c Merge pull request #789 from shlinkio/develop
Release 3.9.1
2022-12-31 18:22:38 +01:00
Alejandro Celaya
597f2b69e9 Merge pull request #788 from acelaya-forks/feature/fix-base-path
Fixed wrong base path being used in vite config
2022-12-31 18:21:26 +01:00
Alejandro Celaya
c078a5fb55 Fixed wrong base path being used in vite config 2022-12-31 18:15:47 +01:00
352 changed files with 4785 additions and 11423 deletions

View File

@@ -1,5 +1,4 @@
./.github ./.github
./.stryker-tmp
./build ./build
./coverage ./coverage
./node_modules ./node_modules

View File

@@ -12,6 +12,5 @@ jobs:
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: 18.12 node-version: 18.12
with-mutation-tests: true
publish-coverage: true publish-coverage: true
force-install: true force-install: true

1
.gitignore vendored
View File

@@ -3,7 +3,6 @@
# testing # testing
/coverage /coverage
/.stryker-tmp
/reports /reports
# production # production

View File

@@ -4,6 +4,47 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## [3.10.0] - 2023-03-19
### Added
* [#807](https://github.com/shlinkio/shlink-web-client/issues/807) Add support for device-specific long-URLs when creating or editing short URLs.
* [#808](https://github.com/shlinkio/shlink-web-client/issues/808) Respect settings on excluding bots in the overview section, for visits cards.
* [#809](https://github.com/shlinkio/shlink-web-client/issues/809) Respect settings on excluding bots in the tags list.
### Changed
* [#798](https://github.com/shlinkio/shlink-web-client/issues/798) Remove stryker and mutation testing.
* [#800](https://github.com/shlinkio/shlink-web-client/issues/800) Use `/tags/stats` endpoint to load tags stats, when the server supports it.
* Update to Vite 4.2
* Update to TypeScript 5
* Update to coding standard v2.1.0
* Decouple tests from RTK internals.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#799](https://github.com/shlinkio/shlink-web-client/issues/799) Fix fallback visits not taking into account configuration regarding excluding bots.
## [3.9.1] - 2022-12-31
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#787](https://github.com/shlinkio/shlink-web-client/issues/787) Fixed wrong base path set in vite config when homepage is set as empty string.
## [3.9.0] - 2022-12-31 ## [3.9.0] - 2022-12-31
### Added ### Added
* [#750](https://github.com/shlinkio/shlink-web-client/issues/750) Added new icon indicators telling if a short URL can be normally visited, it received the max amount of visits, is still not enabled, etc. * [#750](https://github.com/shlinkio/shlink-web-client/issues/750) Added new icon indicators telling if a short URL can be normally visited, it received the max amount of visits, is still not enabled, etc.

View File

@@ -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/twitter/follow/shlinkio?color=blue&label=follow&logo=twitter&style=flat-square)](https://twitter.com/shlinkio) [![Twitter](https://img.shields.io/badge/follow-shlinkio-blue.svg?style=flat-square&logo=twitter&color=blue)](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)
@@ -54,7 +54,7 @@ Those servers can be exported and imported in other browsers, but if for some re
[ [
{ {
"name": "Main server", "name": "Main server",
"url": "https://doma.in", "url": "https://s.test",
"apiKey": "09c972b7-506b-49f1-a19a-d729e22e599c" "apiKey": "09c972b7-506b-49f1-a19a-d729e22e599c"
}, },
{ {
@@ -85,7 +85,7 @@ If you want to pre-configure a single server, you can provide its config via env
docker run \ docker run \
--name shlink-web-client \ --name shlink-web-client \
-p 8000:80 \ -p 8000:80 \
-e SHLINK_SERVER_URL=https://doma.in \ -e SHLINK_SERVER_URL=https://s.test \
-e SHLINK_SERVER_API_KEY=6aeb82c6-e275-4538-a747-31f9abfba63c \ -e SHLINK_SERVER_API_KEY=6aeb82c6-e275-4538-a747-31f9abfba63c \
shlinkio/shlink-web-client shlinkio/shlink-web-client
``` ```

View File

@@ -10,4 +10,4 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
- "56745:56745" - "56745:56745"
- "5000:5000" - "4173:4173"

View File

@@ -10,14 +10,13 @@ module.exports = {
coverageThreshold: { coverageThreshold: {
global: { global: {
statements: 90, statements: 90,
branches: 80, branches: 85,
functions: 85, functions: 90,
lines: 90, lines: 90,
}, },
}, },
setupFilesAfterEnv: ['<rootDir>/config/jest/setupTests.ts'], setupFilesAfterEnv: ['<rootDir>/config/jest/setupTests.ts'],
testMatch: ['<rootDir>/test/**/*.test.{ts,tsx}'], testMatch: ['<rootDir>/test/**/*.test.{ts,tsx}'],
modulePathIgnorePatterns: ['<rootDir>/.stryker-tmp'],
testEnvironment: 'jsdom', testEnvironment: 'jsdom',
testEnvironmentOptions: { testEnvironmentOptions: {
url: 'http://localhost', url: 'http://localhost',
@@ -28,7 +27,6 @@ module.exports = {
'^(?!.*\\.(ts|tsx|js|json|scss)$)': '<rootDir>/config/jest/fileTransform.js', '^(?!.*\\.(ts|tsx|js|json|scss)$)': '<rootDir>/config/jest/fileTransform.js',
}, },
transformIgnorePatterns: [ transformIgnorePatterns: [
'<rootDir>/.stryker-tmp',
'node_modules\/(?!(\@react-leaflet|react-leaflet|leaflet|react-chartjs-2|react-colorful)\/)', 'node_modules\/(?!(\@react-leaflet|react-leaflet|leaflet|react-chartjs-2|react-colorful)\/)',
'^.+\\.module\\.scss$', '^.+\\.module\\.scss$',
], ],

11254
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,25 +12,26 @@
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix", "lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
"lint:css:fix": "npm run lint:css -- --fix", "lint:css:fix": "npm run lint:css -- --fix",
"lint:js:fix": "npm run lint:js -- --fix", "lint:js:fix": "npm run lint:js -- --fix",
"types": "tsc",
"start": "vite serve --host=0.0.0.0", "start": "vite serve --host=0.0.0.0",
"build": "tsc --noEmit && vite build && node scripts/replace-version.mjs", "preview": "vite preview --host=0.0.0.0",
"build": "npm run types && vite build && node scripts/replace-version.mjs",
"build:dist": "npm run build && node scripts/create-dist-file.mjs", "build:dist": "npm run build && node scripts/create-dist-file.mjs",
"build:serve": "serve -p 5000 ./build",
"test": "jest --env=jsdom --colors", "test": "jest --env=jsdom --colors",
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary", "test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
"test:ci": "npm run test:coverage -- --coverageReporters=clover --ci", "test:ci": "npm run test:coverage -- --coverageReporters=clover --ci",
"test:pretty": "npm run test:coverage -- --coverageReporters=html", "test:pretty": "npm run test:coverage -- --coverageReporters=html",
"test:verbose": "npm run test -- --verbose", "test:verbose": "npm run test -- --verbose"
"mutate": "./node_modules/.bin/stryker run --concurrency 4 --ignoreStatic"
}, },
"dependencies": { "dependencies": {
"@babel/preset-env": "^7.20.2", "@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6", "@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.18.6", "@babel/preset-typescript": "^7.21.0",
"@fortawesome/fontawesome-free": "^6.2.1", "@fortawesome/fontawesome-free": "^6.3.0",
"@fortawesome/fontawesome-svg-core": "^6.2.1", "@fortawesome/fontawesome-svg-core": "^6.3.0",
"@fortawesome/free-regular-svg-icons": "^6.2.1", "@fortawesome/free-brands-svg-icons": "^6.3.0",
"@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/free-regular-svg-icons": "^6.3.0",
"@fortawesome/free-solid-svg-icons": "^6.3.0",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@json2csv/plainjs": "^6.1.2", "@json2csv/plainjs": "^6.1.2",
"@reduxjs/toolkit": "^1.9.1", "@reduxjs/toolkit": "^1.9.1",
@@ -71,11 +72,8 @@
"workbox-strategies": "^6.5.4" "workbox-strategies": "^6.5.4"
}, },
"devDependencies": { "devDependencies": {
"@shlinkio/eslint-config-js-coding-standard": "~2.0.2", "@shlinkio/eslint-config-js-coding-standard": "~2.1.0",
"@shlinkio/stylelint-config-css-coding-standard": "~1.0.1", "@shlinkio/stylelint-config-css-coding-standard": "~1.0.1",
"@stryker-mutator/core": "^6.3.1",
"@stryker-mutator/jest-runner": "^6.3.1",
"@stryker-mutator/typescript-checker": "^6.3.1",
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3", "@testing-library/user-event": "^14.4.3",
@@ -91,9 +89,9 @@
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@types/react-tag-autocomplete": "^6.3.0", "@types/react-tag-autocomplete": "^6.3.0",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"@vitejs/plugin-react": "^3.0.0", "@vitejs/plugin-react": "^3.1.0",
"adm-zip": "^0.5.10", "adm-zip": "^0.5.10",
"babel-jest": "^29.3.1", "babel-jest": "^29.5.0",
"chalk": "^5.2.0", "chalk": "^5.2.0",
"eslint": "^8.30.0", "eslint": "^8.30.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
@@ -102,13 +100,11 @@
"jest-environment-jsdom": "^29.3.1", "jest-environment-jsdom": "^29.3.1",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"sass": "^1.57.1", "sass": "^1.57.1",
"serve": "^14.1.2",
"stryker-cli": "^1.0.2",
"stylelint": "^14.16.0", "stylelint": "^14.16.0",
"ts-mockery": "^1.2.0", "ts-mockery": "^1.2.0",
"typescript": "^4.9.4", "typescript": "^5.0.2",
"vite": "^4.0.3", "vite": "^4.2.0",
"vite-plugin-pwa": "^0.14.0" "vite-plugin-pwa": "^0.14.4"
}, },
"browserslist": [ "browserslist": [
">0.2%", ">0.2%",

View File

@@ -1,5 +1,5 @@
import type { ProblemDetailsError } from './types/errors';
import { isInvalidArgumentError } from './utils'; import { isInvalidArgumentError } from './utils';
import { ProblemDetailsError } from './types/errors';
export interface ShlinkApiErrorProps { export interface ShlinkApiErrorProps {
errorData?: ProblemDetailsError; errorData?: ProblemDetailsError;

View File

@@ -1,26 +1,27 @@
import { isEmpty, isNil, reject } from 'ramda'; import { isEmpty, isNil, reject } from 'ramda';
import { ShortUrl, ShortUrlData } from '../../short-urls/data'; import type { HttpClient } from '../../common/services/HttpClient';
import { OptionalString } from '../../utils/utils'; import type { ShortUrl, ShortUrlData } from '../../short-urls/data';
import { import { orderToString } from '../../utils/helpers/ordering';
import { stringifyQuery } from '../../utils/helpers/query';
import type { OptionalString } from '../../utils/utils';
import type {
ShlinkDomainRedirects,
ShlinkDomainsResponse,
ShlinkEditDomainRedirects,
ShlinkHealth, ShlinkHealth,
ShlinkMercureInfo, ShlinkMercureInfo,
ShlinkShortUrlData,
ShlinkShortUrlsListNormalizedParams,
ShlinkShortUrlsListParams,
ShlinkShortUrlsResponse, ShlinkShortUrlsResponse,
ShlinkTags, ShlinkTags,
ShlinkTagsResponse, ShlinkTagsResponse,
ShlinkTagsStatsResponse,
ShlinkVisits, ShlinkVisits,
ShlinkVisitsParams,
ShlinkShortUrlData,
ShlinkDomainsResponse,
ShlinkVisitsOverview, ShlinkVisitsOverview,
ShlinkEditDomainRedirects, ShlinkVisitsParams,
ShlinkDomainRedirects,
ShlinkShortUrlsListParams,
ShlinkShortUrlsListNormalizedParams,
} from '../types'; } from '../types';
import { orderToString } from '../../utils/helpers/ordering';
import { isRegularNotFound, parseApiError } from '../utils'; import { isRegularNotFound, parseApiError } from '../utils';
import { stringifyQuery } from '../../utils/helpers/query';
import { HttpClient } from '../../common/services/HttpClient';
const buildShlinkBaseUrl = (url: string, version: 2 | 3) => `${url}/rest/v${version}`; const buildShlinkBaseUrl = (url: string, version: 2 | 3) => `${url}/rest/v${version}`;
const rejectNilProps = reject(isNil); const rejectNilProps = reject(isNil);
@@ -90,6 +91,11 @@ export class ShlinkApiClient {
.then(({ tags }) => tags) .then(({ tags }) => tags)
.then(({ data, stats }) => ({ tags: data, stats })); .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[] }> => public readonly deleteTags = async (tags: string[]): Promise<{ tags: string[] }> =>
this.performEmptyRequest('/tags', 'DELETE', { tags }).then(() => ({ tags })); this.performEmptyRequest('/tags', 'DELETE', { tags }).then(() => ({ tags }));

View File

@@ -1,7 +1,8 @@
import { hasServerData, ServerWithId } from '../../servers/data'; import type { HttpClient } from '../../common/services/HttpClient';
import { GetState } from '../../container/types'; import type { GetState } from '../../container/types';
import type { ServerWithId } from '../../servers/data';
import { hasServerData } from '../../servers/data';
import { ShlinkApiClient } from './ShlinkApiClient'; import { ShlinkApiClient } from './ShlinkApiClient';
import { HttpClient } from '../../common/services/HttpClient';
const apiClients: Record<string, ShlinkApiClient> = {}; const apiClients: Record<string, ShlinkApiClient> = {};

View File

@@ -1,8 +1,6 @@
import Bottle from 'bottlejs'; import type Bottle from 'bottlejs';
import { buildShlinkApiClient } from './ShlinkApiClientBuilder'; import { buildShlinkApiClient } from './ShlinkApiClientBuilder';
const provideServices = (bottle: Bottle) => { export const provideServices = (bottle: Bottle) => {
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'HttpClient'); bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'HttpClient');
}; };
export default provideServices;

View File

@@ -1,7 +1,7 @@
import { Visit } from '../../visits/types'; import type { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
import { OptionalString } from '../../utils/utils'; import type { Order } from '../../utils/helpers/ordering';
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data'; import type { OptionalString } from '../../utils/utils';
import { Order } from '../../utils/helpers/ordering'; import type { Visit } from '../../visits/types';
export interface ShlinkShortUrlsResponse { export interface ShlinkShortUrlsResponse {
data: ShortUrl[]; data: ShortUrl[];
@@ -18,9 +18,12 @@ export interface ShlinkHealth {
version: string; version: string;
} }
interface ShlinkTagsStats { export interface ShlinkTagsStats {
tag: string; tag: string;
shortUrlsCount: number; shortUrlsCount: number;
visitsSummary?: ShlinkVisitsSummary; // Optional only before Shlink 3.5.0
/** @deprecated */
visitsCount: number; visitsCount: number;
} }
@@ -31,22 +34,38 @@ export interface ShlinkTags {
export interface ShlinkTagsResponse { export interface ShlinkTagsResponse {
data: string[]; data: string[];
/** @deprecated Present only when withStats=true is provided, which is deprecated */
stats: ShlinkTagsStats[]; stats: ShlinkTagsStats[];
} }
export interface ShlinkTagsStatsResponse {
data: ShlinkTagsStats[];
}
export interface ShlinkPaginator { export interface ShlinkPaginator {
currentPage: number; currentPage: number;
pagesCount: number; pagesCount: number;
totalItems: number; totalItems: number;
} }
export interface ShlinkVisitsSummary {
total: number;
nonBots: number;
bots: number;
}
export interface ShlinkVisits { export interface ShlinkVisits {
data: Visit[]; data: Visit[];
pagination: ShlinkPaginator; pagination: ShlinkPaginator;
} }
export interface ShlinkVisitsOverview { export interface ShlinkVisitsOverview {
nonOrphanVisits?: ShlinkVisitsSummary; // Optional only before Shlink 3.5.0
orphanVisits?: ShlinkVisitsSummary; // Optional only before Shlink 3.5.0
/** @deprecated */
visitsCount: number; visitsCount: number;
/** @deprecated */
orphanVisitsCount: number; orphanVisitsCount: number;
} }

View File

@@ -1,10 +1,11 @@
import { import type {
ErrorTypeV2,
ErrorTypeV3,
InvalidArgumentError, InvalidArgumentError,
InvalidShortUrlDeletion, InvalidShortUrlDeletion,
ProblemDetailsError, ProblemDetailsError,
RegularNotFound, RegularNotFound } from '../types/errors';
import {
ErrorTypeV2,
ErrorTypeV3,
} from '../types/errors'; } from '../types/errors';
const isProblemDetails = (e: unknown): e is ProblemDetailsError => const isProblemDetails = (e: unknown): e is ProblemDetailsError =>

View File

@@ -1,12 +1,13 @@
import { useEffect, FC } from 'react';
import { Route, Routes, useLocation } from 'react-router-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import { NotFound } from '../common/NotFound'; import type { FC } from 'react';
import { ServersMap } from '../servers/data'; import { useEffect } from 'react';
import { Settings } from '../settings/reducers/settings'; import { Route, Routes, useLocation } from 'react-router-dom';
import { changeThemeInMarkup } from '../utils/theme';
import { AppUpdateBanner } from '../common/AppUpdateBanner'; import { AppUpdateBanner } from '../common/AppUpdateBanner';
import { NotFound } from '../common/NotFound';
import type { ServersMap } from '../servers/data';
import type { Settings } 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 { interface AppProps {

View File

@@ -1,9 +1,9 @@
import Bottle from 'bottlejs'; import type Bottle from 'bottlejs';
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates'; import type { ConnectDecorator } from '../../container/types';
import { App } from '../App'; import { App } from '../App';
import { ConnectDecorator } from '../../container/types'; import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory( bottle.serviceFactory(
'App', 'App',
@@ -23,5 +23,3 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable); bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);
bottle.serviceFactory('resetAppUpdate', () => resetAppUpdate); bottle.serviceFactory('resetAppUpdate', () => resetAppUpdate);
}; };
export default provideServices;

View File

@@ -1,9 +1,9 @@
import { FC, MouseEventHandler } from 'react';
import { Alert, Button } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons'; import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
import { SimpleCard } from '../utils/SimpleCard'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC, MouseEventHandler } from 'react';
import { Alert, Button } from 'reactstrap';
import { useToggle } from '../utils/helpers/hooks'; import { useToggle } from '../utils/helpers/hooks';
import { SimpleCard } from '../utils/SimpleCard';
import './AppUpdateBanner.scss'; import './AppUpdateBanner.scss';
interface AppUpdateBannerProps { interface AppUpdateBannerProps {

View File

@@ -1,17 +1,19 @@
import { import {
faList as listIcon,
faLink as createIcon,
faTags as tagsIcon,
faPen as editIcon,
faHome as overviewIcon,
faGlobe as domainsIcon, faGlobe as domainsIcon,
faHome as overviewIcon,
faLink as createIcon,
faList as listIcon,
faPen as editIcon,
faTags as tagsIcon,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { FC } from 'react';
import { NavLink, NavLinkProps, useLocation } from 'react-router-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import { DeleteServerButtonProps } from '../servers/DeleteServerButton'; import type { FC } from 'react';
import { isServerWithId, SelectedServer } from '../servers/data'; 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'; import './AsideMenu.scss';
export interface AsideMenuProps { export interface AsideMenuProps {

View File

@@ -1,4 +1,5 @@
import { Component, ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Component } from 'react';
import { Button } from 'reactstrap'; import { Button } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';

View File

@@ -1,12 +1,12 @@
import { useEffect } from 'react'; import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, values } from 'ramda'; import { isEmpty, values } from 'ramda';
import { useEffect } from 'react';
import { ExternalLink } from 'react-external-link';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { Card, Row } from 'reactstrap'; import { Card, Row } from 'reactstrap';
import { ExternalLink } from 'react-external-link'; import type { ServersMap } from '../servers/data';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
import { ServersListGroup } from '../servers/ServersListGroup'; import { ServersListGroup } from '../servers/ServersListGroup';
import { ServersMap } from '../servers/data';
import { ShlinkLogo } from './img/ShlinkLogo'; import { ShlinkLogo } from './img/ShlinkLogo';
import './Home.scss'; import './Home.scss';

View File

@@ -1,9 +1,10 @@
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 { FC, useEffect } from 'react'; import classNames from 'classnames';
import type { FC } 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 classNames from 'classnames';
import { useToggle } from '../utils/helpers/hooks'; import { useToggle } from '../utils/helpers/hooks';
import { ShlinkLogo } from './img/ShlinkLogo'; import { ShlinkLogo } from './img/ShlinkLogo';
import './MainHeader.scss'; import './MainHeader.scss';

View File

@@ -1,14 +1,15 @@
import { FC, useEffect } from 'react';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons'; import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames'; import classNames from 'classnames';
import { withSelectedServer } from '../servers/helpers/withSelectedServer'; import type { FC } from 'react';
import { useSwipeable, useToggle } from '../utils/helpers/hooks'; import { useEffect } from 'react';
import { supportsDomainVisits, supportsNonOrphanVisits } from '../utils/helpers/features'; import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { isReachableServer } from '../servers/data'; 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 { NotFound } from './NotFound';
import { AsideMenuProps } from './AsideMenu';
import './MenuLayout.scss'; import './MenuLayout.scss';
interface MenuLayoutProps { interface MenuLayoutProps {
@@ -45,8 +46,8 @@ export const MenuLayout = (
return <ServerError />; return <ServerError />;
} }
const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer); const addNonOrphanVisitsRoute = useFeature('nonOrphanVisits', selectedServer);
const addDomainVisitsRoute = supportsDomainVisits(selectedServer); const addDomainVisitsRoute = useFeature('domainVisits', selectedServer);
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible }); const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
const swipeableProps = useSwipeable(showSidebar, hideSidebar); const swipeableProps = useSwipeable(showSidebar, hideSidebar);

View File

@@ -1,4 +1,4 @@
import { FC, PropsWithChildren } from 'react'; import type { FC, PropsWithChildren } from 'react';
import './NoMenuLayout.scss'; import './NoMenuLayout.scss';
export const NoMenuLayout: FC<PropsWithChildren<unknown>> = ({ children }) => ( export const NoMenuLayout: FC<PropsWithChildren<unknown>> = ({ children }) => (

View File

@@ -1,4 +1,4 @@
import { 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'; import { SimpleCard } from '../utils/SimpleCard';

View File

@@ -1,4 +1,5 @@
import { FC, PropsWithChildren, useEffect } from 'react'; import type { FC, PropsWithChildren } 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<unknown>> = ({ children }) => {

View File

@@ -1,7 +1,8 @@
import { pipe } from 'ramda'; import { pipe } from 'ramda';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import type { SelectedServer } from '../servers/data';
import { isReachableServer } from '../servers/data';
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version'; import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
import { isReachableServer, SelectedServer } from '../servers/data';
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%'; const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable); const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);

View File

@@ -1,7 +1,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { 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 { Sidebar } from './reducers/sidebar';
import './ShlinkVersionsContainer.scss'; import './ShlinkVersionsContainer.scss';
export interface ShlinkVersionsContainerProps { export interface ShlinkVersionsContainerProps {

View File

@@ -1,12 +1,13 @@
import { FC } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import type { FC } from 'react';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap'; import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import type {
NumberOrEllipsis } from '../utils/helpers/pagination';
import { import {
pageIsEllipsis,
keyForPage, keyForPage,
NumberOrEllipsis, pageIsEllipsis,
progressivePagination,
prettifyPageNumber, prettifyPageNumber,
progressivePagination,
} from '../utils/helpers/pagination'; } from '../utils/helpers/pagination';
import './SimplePaginator.scss'; import './SimplePaginator.scss';

View File

@@ -3,7 +3,7 @@
.react-tags { .react-tags {
position: relative; position: relative;
padding: 5px 0 0 6px; padding: 5px 0 0 6px;
border-radius: .3rem; border-radius: .5rem;
background-color: var(--primary-color); background-color: var(--primary-color);
border: 1px solid var(--input-border-color); border: 1px solid var(--input-border-color);
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;

View File

@@ -1,4 +1,4 @@
import { Fetch } from '../../utils/types'; import type { Fetch } from '../../utils/types';
const applicationJsonHeader = { 'Content-Type': 'application/json' }; const applicationJsonHeader = { 'Content-Type': 'application/json' };
const withJsonContentType = (options?: RequestInit): RequestInit | undefined => { const withJsonContentType = (options?: RequestInit): RequestInit | undefined => {

View File

@@ -1,5 +1,5 @@
import { saveUrl } from '../../utils/helpers/files'; import { saveUrl } from '../../utils/helpers/files';
import { HttpClient } from './HttpClient'; import type { HttpClient } from './HttpClient';
export class ImageDownloader { export class ImageDownloader {
public constructor(private readonly httpClient: HttpClient, private readonly window: Window) {} public constructor(private readonly httpClient: HttpClient, private readonly window: Window) {}

View File

@@ -1,7 +1,7 @@
import { NormalizedVisit } from '../../visits/types'; import type { ExportableShortUrl } from '../../short-urls/data';
import { ExportableShortUrl } from '../../short-urls/data'; import type { JsonToCsv } from '../../utils/helpers/csvjson';
import { saveCsv } from '../../utils/helpers/files'; import { saveCsv } from '../../utils/helpers/files';
import { JsonToCsv } from '../../utils/helpers/csvjson'; import type { NormalizedVisit } from '../../visits/types';
export class ReportExporter { export class ReportExporter {
public constructor(private readonly window: Window, private readonly jsonToCsv: JsonToCsv) {} public constructor(private readonly window: Window, private readonly jsonToCsv: JsonToCsv) {}

View File

@@ -1,19 +1,19 @@
import Bottle from 'bottlejs'; import type Bottle from 'bottlejs';
import { ScrollToTop } from '../ScrollToTop'; import type { ConnectDecorator } from '../../container/types';
import { MainHeader } from '../MainHeader'; import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { Home } from '../Home';
import { MenuLayout } from '../MenuLayout';
import { AsideMenu } from '../AsideMenu'; import { AsideMenu } from '../AsideMenu';
import { ErrorHandler } from '../ErrorHandler'; import { ErrorHandler } from '../ErrorHandler';
import { ShlinkVersionsContainer } from '../ShlinkVersionsContainer'; import { Home } from '../Home';
import { ConnectDecorator } from '../../container/types'; import { MainHeader } from '../MainHeader';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer'; import { MenuLayout } from '../MenuLayout';
import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar'; import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar';
import { ScrollToTop } from '../ScrollToTop';
import { ShlinkVersionsContainer } from '../ShlinkVersionsContainer';
import { HttpClient } from './HttpClient';
import { ImageDownloader } from './ImageDownloader'; import { ImageDownloader } from './ImageDownloader';
import { ReportExporter } from './ReportExporter'; import { ReportExporter } from './ReportExporter';
import { HttpClient } from './HttpClient';
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);
@@ -62,5 +62,3 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('sidebarPresent', () => sidebarPresent); bottle.serviceFactory('sidebarPresent', () => sidebarPresent);
bottle.serviceFactory('sidebarNotPresent', () => sidebarNotPresent); bottle.serviceFactory('sidebarNotPresent', () => sidebarNotPresent);
}; };
export default provideServices;

View File

@@ -1,18 +1,19 @@
import Bottle, { IContainer } from 'bottlejs'; import type { IContainer } from 'bottlejs';
import { connect as reduxConnect } from 'react-redux'; import Bottle from 'bottlejs';
import { pick } from 'ramda'; import { pick } from 'ramda';
import provideApiServices from '../api/services/provideServices'; import { connect as reduxConnect } from 'react-redux';
import provideCommonServices from '../common/services/provideServices'; import { provideServices as provideApiServices } from '../api/services/provideServices';
import provideShortUrlsServices from '../short-urls/services/provideServices'; import { provideServices as provideAppServices } from '../app/services/provideServices';
import provideServersServices from '../servers/services/provideServices'; import { provideServices as provideCommonServices } from '../common/services/provideServices';
import provideVisitsServices from '../visits/services/provideServices'; import { provideServices as provideDomainsServices } from '../domains/services/provideServices';
import provideTagsServices from '../tags/services/provideServices'; import { provideServices as provideMercureServices } from '../mercure/services/provideServices';
import provideUtilsServices from '../utils/services/provideServices'; import { provideServices as provideServersServices } from '../servers/services/provideServices';
import provideMercureServices from '../mercure/services/provideServices'; import { provideServices as provideSettingsServices } from '../settings/services/provideServices';
import provideSettingsServices from '../settings/services/provideServices'; import { provideServices as provideShortUrlsServices } from '../short-urls/services/provideServices';
import provideDomainsServices from '../domains/services/provideServices'; import { provideServices as provideTagsServices } from '../tags/services/provideServices';
import provideAppServices from '../app/services/provideServices'; import { provideServices as provideUtilsServices } from '../utils/services/provideServices';
import { ConnectDecorator } from './types'; import { provideServices as provideVisitsServices } from '../visits/services/provideServices';
import type { ConnectDecorator } from './types';
type LazyActionMap = Record<string, Function>; type LazyActionMap = Record<string, Function>;

View File

@@ -1,9 +1,10 @@
import { IContainer } from 'bottlejs';
import { save, load, RLSOptions } from 'redux-localstorage-simple';
import { configureStore } from '@reduxjs/toolkit'; import { configureStore } from '@reduxjs/toolkit';
import reducer from '../reducers'; import type { IContainer } from 'bottlejs';
import type { RLSOptions } from 'redux-localstorage-simple';
import { load, save } from 'redux-localstorage-simple';
import { initReducers } from '../reducers';
import { migrateDeprecatedSettings } from '../settings/helpers'; import { migrateDeprecatedSettings } from '../settings/helpers';
import { ShlinkState } from './types'; import type { ShlinkState } from './types';
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
const localStorageConfig: RLSOptions = { const localStorageConfig: RLSOptions = {
@@ -16,7 +17,7 @@ const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as Shl
export const setUpStore = (container: IContainer) => configureStore({ export const setUpStore = (container: IContainer) => configureStore({
devTools: !isProduction, devTools: !isProduction,
reducer: reducer(container), reducer: initReducers(container),
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

View File

@@ -1,21 +1,21 @@
import { MercureInfo } from '../mercure/reducers/mercureInfo'; import type { Sidebar } from '../common/reducers/sidebar';
import { SelectedServer, ServersMap } from '../servers/data'; import type { DomainsList } from '../domains/reducers/domainsList';
import { Settings } from '../settings/reducers/settings'; import type { MercureInfo } from '../mercure/reducers/mercureInfo';
import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation'; import type { SelectedServer, ServersMap } from '../servers/data';
import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion'; import type { Settings } from '../settings/reducers/settings';
import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition'; import type { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList'; import type { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
import { TagDeletion } from '../tags/reducers/tagDelete'; import type { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
import { TagEdition } from '../tags/reducers/tagEdit'; import type { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
import { TagsList } from '../tags/reducers/tagsList'; import type { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail'; import type { TagDeletion } from '../tags/reducers/tagDelete';
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits'; import type { TagEdition } from '../tags/reducers/tagEdit';
import { TagVisits } from '../visits/reducers/tagVisits'; import type { TagsList } from '../tags/reducers/tagsList';
import { DomainsList } from '../domains/reducers/domainsList'; import type { DomainVisits } from '../visits/reducers/domainVisits';
import { VisitsOverview } from '../visits/reducers/visitsOverview'; import type { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
import { Sidebar } from '../common/reducers/sidebar'; import type { TagVisits } from '../visits/reducers/tagVisits';
import { DomainVisits } from '../visits/reducers/domainVisits'; import type { VisitsInfo } from '../visits/reducers/types';
import { VisitsInfo } from '../visits/reducers/types'; import type { VisitsOverview } from '../visits/reducers/visitsOverview';
export interface ShlinkState { export interface ShlinkState {
servers: ServersMap; servers: ServersMap;

View File

@@ -1,14 +1,15 @@
import { FC, useEffect } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faDotCircle as defaultDomainIcon } from '@fortawesome/free-solid-svg-icons'; import { faDotCircle as defaultDomainIcon } from '@fortawesome/free-solid-svg-icons';
import { ShlinkDomainRedirects } from '../api/types'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { OptionalString } from '../utils/utils'; import type { FC } from 'react';
import { SelectedServer } from '../servers/data'; import { useEffect } from 'react';
import { Domain } from './data'; import { UncontrolledTooltip } from 'reactstrap';
import { DomainStatusIcon } from './helpers/DomainStatusIcon'; 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 { DomainDropdown } from './helpers/DomainDropdown';
import { EditDomainRedirects } from './reducers/domainRedirects'; import { DomainStatusIcon } from './helpers/DomainStatusIcon';
import type { EditDomainRedirects } from './reducers/domainRedirects';
interface DomainRowProps { interface DomainRowProps {
domain: Domain; domain: Domain;

View File

@@ -1,11 +1,12 @@
import { useEffect } from 'react';
import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip, InputProps } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUndo } from '@fortawesome/free-solid-svg-icons'; import { faUndo } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, pipe } from 'ramda'; 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 { DropdownBtn } from '../utils/DropdownBtn';
import { useToggle } from '../utils/helpers/hooks'; import { useToggle } from '../utils/helpers/hooks';
import { DomainsList } from './reducers/domainsList'; import type { DomainsList } from './reducers/domainsList';
import './DomainSelector.scss'; import './DomainSelector.scss';
export interface DomainSelectorProps extends Omit<InputProps, 'onChange'> { export interface DomainSelectorProps extends Omit<InputProps, 'onChange'> {

View File

@@ -1,13 +1,14 @@
import { FC, useEffect } from 'react'; 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 { Message } from '../utils/Message';
import { Result } from '../utils/Result'; import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError';
import { SimpleCard } from '../utils/SimpleCard';
import { SearchField } from '../utils/SearchField'; import { SearchField } from '../utils/SearchField';
import { EditDomainRedirects } from './reducers/domainRedirects'; import { SimpleCard } from '../utils/SimpleCard';
import { SelectedServer } from '../servers/data';
import { DomainsList } from './reducers/domainsList';
import { DomainRow } from './DomainRow'; import { DomainRow } from './DomainRow';
import type { EditDomainRedirects } from './reducers/domainRedirects';
import type { DomainsList } from './reducers/domainsList';
interface ManageDomainsProps { interface ManageDomainsProps {
listDomains: Function; listDomains: Function;

View File

@@ -1,4 +1,4 @@
import { ShlinkDomain } from '../../api/types'; import type { ShlinkDomain } from '../../api/types';
export type DomainStatus = 'validating' | 'valid' | 'invalid'; export type DomainStatus = 'validating' | 'valid' | 'invalid';

View File

@@ -1,16 +1,17 @@
import { FC } from 'react';
import { DropdownItem } from 'reactstrap';
import { Link } from 'react-router-dom';
import { faChartPie as pieChartIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons'; import { faChartPie as pieChartIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '../../utils/helpers/hooks'; 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 { DropdownBtnMenu } from '../../utils/DropdownBtnMenu'; import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
import { EditDomainRedirectsModal } from './EditDomainRedirectsModal'; import { useFeature } from '../../utils/helpers/features';
import { Domain } from '../data'; import { useToggle } from '../../utils/helpers/hooks';
import { EditDomainRedirects } from '../reducers/domainRedirects';
import { supportsDefaultDomainRedirectsEdition, supportsDomainVisits } from '../../utils/helpers/features';
import { getServerId, SelectedServer } from '../../servers/data';
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits'; import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
import type { Domain } from '../data';
import type { EditDomainRedirects } from '../reducers/domainRedirects';
import { EditDomainRedirectsModal } from './EditDomainRedirectsModal';
interface DomainDropdownProps { interface DomainDropdownProps {
domain: Domain; domain: Domain;
@@ -22,8 +23,8 @@ export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedi
const [isOpen, toggle] = useToggle(); const [isOpen, toggle] = useToggle();
const [isModalOpen, toggleModal] = useToggle(); const [isModalOpen, toggleModal] = useToggle();
const { isDefault } = domain; const { isDefault } = domain;
const canBeEdited = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer); const canBeEdited = !isDefault || useFeature('defaultDomainRedirectsEdition', selectedServer);
const withVisits = supportsDomainVisits(selectedServer); const withVisits = useFeature('domainVisits', selectedServer);
const serverId = getServerId(selectedServer); const serverId = getServerId(selectedServer);
return ( return (

View File

@@ -1,15 +1,16 @@
import { FC, useEffect, useRef, useState } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import { ExternalLink } from 'react-external-link';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faTimes as invalidIcon,
faCheck as checkIcon, faCheck as checkIcon,
faCircleNotch as loadingStatusIcon, faCircleNotch as loadingStatusIcon,
faTimes as invalidIcon,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { MediaMatcher } from '../../utils/types'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { mutableRefToElementRef } from '../../utils/helpers/components'; import type { FC } from 'react';
import { DomainStatus } from '../data'; 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 { interface DomainStatusIconProps {
status: DomainStatus; status: DomainStatus;
@@ -17,7 +18,7 @@ interface DomainStatusIconProps {
} }
export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => { export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => {
const ref = useRef<HTMLSpanElement>(); const ref = useElementRef<HTMLSpanElement>();
const matchesMobile = () => matchMedia('(max-width: 991px)').matches; const matchesMobile = () => matchMedia('(max-width: 991px)').matches;
const [isMobile, setIsMobile] = useState<boolean>(matchesMobile()); const [isMobile, setIsMobile] = useState<boolean>(matchesMobile());
@@ -35,13 +36,13 @@ export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia
return ( return (
<> <>
<span ref={mutableRefToElementRef(ref)}> <span ref={ref}>
{status === 'valid' {status === 'valid'
? <FontAwesomeIcon fixedWidth icon={checkIcon} className="text-muted" /> ? <FontAwesomeIcon fixedWidth icon={checkIcon} className="text-muted" />
: <FontAwesomeIcon fixedWidth icon={invalidIcon} className="text-danger" />} : <FontAwesomeIcon fixedWidth icon={invalidIcon} className="text-danger" />}
</span> </span>
<UncontrolledTooltip <UncontrolledTooltip
target={(() => ref.current) as any} target={ref}
placement={isMobile ? 'top-start' : 'left'} placement={isMobile ? 'top-start' : 'left'}
autohide={status === 'valid'} autohide={status === 'valid'}
> >

View File

@@ -1,10 +1,12 @@
import { FC, useState } from 'react'; import type { FC } from 'react';
import { useState } from 'react';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { ShlinkDomain } from '../../api/types'; import type { ShlinkDomain } from '../../api/types';
import { InputFormGroup, InputFormGroupProps } from '../../utils/forms/InputFormGroup'; import type { InputFormGroupProps } from '../../utils/forms/InputFormGroup';
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils'; import { InputFormGroup } from '../../utils/forms/InputFormGroup';
import { InfoTooltip } from '../../utils/InfoTooltip'; import { InfoTooltip } from '../../utils/InfoTooltip';
import { EditDomainRedirects } from '../reducers/domainRedirects'; import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
import type { EditDomainRedirects } from '../reducers/domainRedirects';
interface EditDomainRedirectsModalProps { interface EditDomainRedirectsModalProps {
domain: ShlinkDomain; domain: ShlinkDomain;

View File

@@ -1,6 +1,6 @@
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import type { ShlinkDomainRedirects } from '../../api/types';
import { createAsyncThunk } from '../../utils/helpers/redux'; import { createAsyncThunk } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ShlinkDomainRedirects } from '../../api/types';
const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS'; const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';

View File

@@ -1,13 +1,14 @@
import { createSlice, createAction, SliceCaseReducers, AsyncThunk } from '@reduxjs/toolkit'; import type { AsyncThunk, SliceCaseReducers } from '@reduxjs/toolkit';
import { createAsyncThunk } from '../../utils/helpers/redux'; import { createAction, createSlice } from '@reduxjs/toolkit';
import { ShlinkDomainRedirects } from '../../api/types'; import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import type { ShlinkDomainRedirects } from '../../api/types';
import { Domain, DomainStatus } from '../data'; import type { ProblemDetailsError } from '../../api/types/errors';
import { hasServerData } from '../../servers/data';
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
import { ProblemDetailsError } from '../../api/types/errors';
import { parseApiError } from '../../api/utils'; import { parseApiError } from '../../api/utils';
import { EditDomainRedirects } from './domainRedirects'; 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'; const REDUCER_PREFIX = 'shlink/domainsList';

View File

@@ -1,12 +1,12 @@
import type Bottle from 'bottlejs';
import { prop } from 'ramda'; import { prop } from 'ramda';
import Bottle from 'bottlejs'; import type { ConnectDecorator } from '../../container/types';
import { ConnectDecorator } from '../../container/types';
import { domainsListReducerCreator } from '../reducers/domainsList';
import { DomainSelector } from '../DomainSelector'; import { DomainSelector } from '../DomainSelector';
import { ManageDomains } from '../ManageDomains'; import { ManageDomains } from '../ManageDomains';
import { editDomainRedirects } from '../reducers/domainRedirects'; import { editDomainRedirects } from '../reducers/domainRedirects';
import { domainsListReducerCreator } from '../reducers/domainsList';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory('DomainSelector', () => DomainSelector); bottle.serviceFactory('DomainSelector', () => DomainSelector);
bottle.decorator('DomainSelector', connect(['domainsList'], ['listDomains'])); bottle.decorator('DomainSelector', connect(['domainsList'], ['listDomains']));
@@ -32,5 +32,3 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient'); bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
bottle.serviceFactory('checkDomainHealth', prop('checkDomainHealth'), 'domainsListReducerCreator'); bottle.serviceFactory('checkDomainHealth', prop('checkDomainHealth'), 'domainsListReducerCreator');
}; };
export default provideServices;

View File

@@ -1,15 +1,15 @@
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';
import pack from '../package.json'; import pack from '../package.json';
import { container } from './container'; import { container } from './container';
import { setUpStore } from './container/store'; import { setUpStore } from './container/store';
import { fixLeafletIcons } from './utils/helpers/leaflet';
import { register as registerServiceWorker } from './serviceWorkerRegistration'; import { register as registerServiceWorker } from './serviceWorkerRegistration';
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 { fixLeafletIcons } from './utils/helpers/leaflet';
import 'react-datepicker/dist/react-datepicker.css';
import 'leaflet/dist/leaflet.css';
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 // This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
fixLeafletIcons(); fixLeafletIcons();

View File

@@ -1,8 +1,9 @@
import { FC, useEffect } from 'react';
import { pipe } from 'ramda'; import { pipe } from 'ramda';
import type { FC } from 'react';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { CreateVisit } from '../../visits/types'; import type { CreateVisit } from '../../visits/types';
import { MercureInfo } from '../reducers/mercureInfo'; import type { MercureInfo } from '../reducers/mercureInfo';
import { bindToMercureTopic } from './index'; import { bindToMercureTopic } from './index';
export interface MercureBoundProps { export interface MercureBoundProps {

View File

@@ -1,5 +1,5 @@
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill'; import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
import { MercureInfo } from '../reducers/mercureInfo'; 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 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; const { mercureHubUrl, token, loading, error } = mercureInfo;

View File

@@ -1,7 +1,7 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import type { ShlinkMercureInfo } from '../../api/types';
import { createAsyncThunk } from '../../utils/helpers/redux'; import { createAsyncThunk } from '../../utils/helpers/redux';
import { ShlinkMercureInfo } from '../../api/types';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
const REDUCER_PREFIX = 'shlink/mercure'; const REDUCER_PREFIX = 'shlink/mercure';

View File

@@ -1,8 +1,8 @@
import type Bottle from 'bottlejs';
import { prop } from 'ramda'; import { prop } from 'ramda';
import Bottle from 'bottlejs';
import { mercureInfoReducerCreator } from '../reducers/mercureInfo'; import { mercureInfoReducerCreator } from '../reducers/mercureInfo';
const provideServices = (bottle: Bottle) => { export const provideServices = (bottle: Bottle) => {
// Reducer // Reducer
bottle.serviceFactory('mercureInfoReducerCreator', mercureInfoReducerCreator, 'buildShlinkApiClient'); bottle.serviceFactory('mercureInfoReducerCreator', mercureInfoReducerCreator, 'buildShlinkApiClient');
bottle.serviceFactory('mercureInfoReducer', prop('reducer'), 'mercureInfoReducerCreator'); bottle.serviceFactory('mercureInfoReducer', prop('reducer'), 'mercureInfoReducerCreator');
@@ -10,5 +10,3 @@ const provideServices = (bottle: Bottle) => {
// Actions // Actions
bottle.serviceFactory('loadMercureInfo', prop('loadMercureInfo'), 'mercureInfoReducerCreator'); bottle.serviceFactory('loadMercureInfo', prop('loadMercureInfo'), 'mercureInfoReducerCreator');
}; };
export default provideServices;

View File

@@ -1,12 +1,12 @@
import { IContainer } from 'bottlejs';
import { combineReducers } from '@reduxjs/toolkit'; import { combineReducers } from '@reduxjs/toolkit';
import { serversReducer } from '../servers/reducers/servers'; import type { IContainer } from 'bottlejs';
import { settingsReducer } from '../settings/reducers/settings';
import { appUpdatesReducer } from '../app/reducers/appUpdates'; import { appUpdatesReducer } from '../app/reducers/appUpdates';
import { sidebarReducer } from '../common/reducers/sidebar'; import { sidebarReducer } from '../common/reducers/sidebar';
import { ShlinkState } from '../container/types'; import type { ShlinkState } from '../container/types';
import { serversReducer } from '../servers/reducers/servers';
import { settingsReducer } from '../settings/reducers/settings';
export default (container: IContainer) => combineReducers<ShlinkState>({ export const initReducers = (container: IContainer) => combineReducers<ShlinkState>({
servers: serversReducer, servers: serversReducer,
selectedServer: container.selectedServerReducer, selectedServer: container.selectedServerReducer,
shortUrlsList: container.shortUrlsListReducer, shortUrlsList: container.shortUrlsListReducer,

View File

@@ -1,14 +1,16 @@
import { FC, useEffect, useState } from 'react'; import type { FC } from 'react';
import { v4 as uuid } from 'uuid'; import { useEffect, useState } from 'react';
import { Button } from 'reactstrap';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Result } from '../utils/Result'; import { Button } from 'reactstrap';
import { v4 as uuid } from 'uuid';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import { TimeoutToggle, useGoBack, useToggle } from '../utils/helpers/hooks'; import type { TimeoutToggle } from '../utils/helpers/hooks';
import { ServerForm } from './helpers/ServerForm'; import { useGoBack, useToggle } from '../utils/helpers/hooks';
import { ImportServersBtnProps } from './helpers/ImportServersBtn'; import { Result } from '../utils/Result';
import { 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 { ServerForm } from './helpers/ServerForm';
const SHOW_IMPORT_MSG_TIME = 4000; const SHOW_IMPORT_MSG_TIME = 4000;

View File

@@ -1,9 +1,9 @@
import { FC, PropsWithChildren } from 'react';
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 type { FC, PropsWithChildren } from 'react';
import { useToggle } from '../utils/helpers/hooks'; import { useToggle } from '../utils/helpers/hooks';
import { DeleteServerModalProps } from './DeleteServerModal'; import type { ServerWithId } from './data';
import { ServerWithId } from './data'; import type { DeleteServerModalProps } from './DeleteServerModal';
export type DeleteServerButtonProps = PropsWithChildren<{ export type DeleteServerButtonProps = PropsWithChildren<{
server: ServerWithId; server: ServerWithId;

View File

@@ -1,7 +1,8 @@
import { FC, useRef } from 'react'; import type { FC } from 'react';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ServerWithId } from './data'; import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import type { ServerWithId } from './data';
export interface DeleteServerModalProps { export interface DeleteServerModalProps {
server: ServerWithId; server: ServerWithId;

View File

@@ -1,10 +1,11 @@
import { 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 { useGoBack, useParsedQuery } from '../utils/helpers/hooks'; import { useGoBack, useParsedQuery } from '../utils/helpers/hooks';
import type { ServerData } from './data';
import { isServerWithId } from './data';
import { ServerForm } from './helpers/ServerForm'; import { ServerForm } from './helpers/ServerForm';
import { withSelectedServer } from './helpers/withSelectedServer'; import { withSelectedServer } from './helpers/withSelectedServer';
import { isServerWithId, ServerData } from './data';
interface EditServerProps { interface EditServerProps {
editServer: (serverId: string, serverData: ServerData) => void; editServer: (serverId: string, serverData: ServerData) => void;

View File

@@ -1,17 +1,18 @@
import { FC, useEffect, useState } from 'react';
import { Button, Row } from 'reactstrap';
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons'; import { 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 { FC } 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 { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import { SimpleCard } from '../utils/SimpleCard'; import type { TimeoutToggle } from '../utils/helpers/hooks';
import { SearchField } from '../utils/SearchField';
import { Result } from '../utils/Result'; import { Result } from '../utils/Result';
import { TimeoutToggle } from '../utils/helpers/hooks'; import { SearchField } from '../utils/SearchField';
import { ImportServersBtnProps } from './helpers/ImportServersBtn'; import { SimpleCard } from '../utils/SimpleCard';
import { ServersMap } from './data'; import type { ServersMap } from './data';
import { ManageServersRowProps } from './ManageServersRow'; import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
import ServersExporter from './services/ServersExporter'; import type { ManageServersRowProps } from './ManageServersRow';
import type { ServersExporter } from './services/ServersExporter';
interface ManageServersProps { interface ManageServersProps {
servers: ServersMap; servers: ServersMap;

View File

@@ -1,10 +1,10 @@
import { FC } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import { Link } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons'; import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons';
import { ServerWithId } from './data'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { ManageServersRowDropdownProps } from './ManageServersRowDropdown'; import type { FC } from 'react';
import { Link } from 'react-router-dom';
import { UncontrolledTooltip } from 'reactstrap';
import type { ServerWithId } from './data';
import type { ManageServersRowDropdownProps } from './ManageServersRowDropdown';
export interface ManageServersRowProps { export interface ManageServersRowProps {
server: ServerWithId; server: ServerWithId;

View File

@@ -1,18 +1,18 @@
import { FC } from 'react'; import { faCircle as toggleOnIcon } from '@fortawesome/free-regular-svg-icons';
import { DropdownItem } from 'reactstrap';
import { Link } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faBan as toggleOffIcon, faBan as toggleOffIcon,
faEdit as editIcon, faEdit as editIcon,
faMinusCircle as deleteIcon, faMinusCircle as deleteIcon,
faPlug as connectIcon, faPlug as connectIcon,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { faCircle as toggleOnIcon } from '@fortawesome/free-regular-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react';
import { Link } from 'react-router-dom';
import { DropdownItem } from 'reactstrap';
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu'; import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
import { useToggle } from '../utils/helpers/hooks'; import { useToggle } from '../utils/helpers/hooks';
import { DeleteServerModalProps } from './DeleteServerModal'; import type { ServerWithId } from './data';
import { ServerWithId } from './data'; import type { DeleteServerModalProps } from './DeleteServerModal';
export interface ManageServersRowDropdownProps { export interface ManageServersRowDropdownProps {
server: ServerWithId; server: ServerWithId;

View File

@@ -1,18 +1,23 @@
import { FC, useEffect } from 'react'; import type { FC } from 'react';
import { Card, CardBody, CardHeader, Row } from 'reactstrap'; import { useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { ITEMS_IN_OVERVIEW_PAGE, ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList'; import { Card, CardBody, CardHeader, Row } from 'reactstrap';
import { prettify } from '../utils/helpers/numbers'; import type { ShlinkShortUrlsListParams } from '../api/types';
import { TagsList } from '../tags/reducers/tagsList';
import { ShortUrlsTableType } from '../short-urls/ShortUrlsTable';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { ShlinkShortUrlsListParams } from '../api/types'; import type { Settings } from '../settings/reducers/settings';
import { supportsNonOrphanVisits } from '../utils/helpers/features'; import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
import { getServerId, SelectedServer } from './data'; 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 { HighlightCard } from './helpers/HighlightCard';
import { VisitsHighlightCard } from './helpers/VisitsHighlightCard';
interface OverviewConnectProps { interface OverviewConnectProps {
shortUrlsList: ShortUrlsListState; shortUrlsList: ShortUrlsListState;
@@ -22,6 +27,7 @@ interface OverviewConnectProps {
selectedServer: SelectedServer; selectedServer: SelectedServer;
visitsOverview: VisitsOverview; visitsOverview: VisitsOverview;
loadVisitsOverview: Function; loadVisitsOverview: Function;
settings: Settings;
} }
export const Overview = ( export const Overview = (
@@ -35,12 +41,13 @@ export const Overview = (
selectedServer, selectedServer,
loadVisitsOverview, loadVisitsOverview,
visitsOverview, visitsOverview,
settings: { visits },
}: OverviewConnectProps) => { }: OverviewConnectProps) => {
const { loading, shortUrls } = shortUrlsList; const { loading, shortUrls } = shortUrlsList;
const { loading: loadingTags } = tagsList; const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview; const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview;
const serverId = getServerId(selectedServer); const serverId = getServerId(selectedServer);
const linkToNonOrphanVisits = supportsNonOrphanVisits(selectedServer); const linkToNonOrphanVisits = useFeature('nonOrphanVisits', selectedServer);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
@@ -53,14 +60,22 @@ export const Overview = (
<> <>
<Row> <Row>
<div className="col-lg-6 col-xl-3 mb-3"> <div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Visits" link={linkToNonOrphanVisits && `/server/${serverId}/non-orphan-visits`}> <VisitsHighlightCard
{loadingVisits ? 'Loading...' : prettify(visitsCount)} title="Visits"
</HighlightCard> link={linkToNonOrphanVisits ? `/server/${serverId}/non-orphan-visits` : undefined}
excludeBots={visits?.excludeBots ?? false}
loading={loadingVisits}
visitsSummary={nonOrphanVisits}
/>
</div> </div>
<div className="col-lg-6 col-xl-3 mb-3"> <div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Orphan visits" link={`/server/${serverId}/orphan-visits`}> <VisitsHighlightCard
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount)} title="Orphan visits"
</HighlightCard> link={`/server/${serverId}/orphan-visits`}
excludeBots={visits?.excludeBots ?? false}
loading={loadingVisits}
visitsSummary={orphanVisits}
/>
</div> </div>
<div className="col-lg-6 col-xl-3 mb-3"> <div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}> <HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}>

View File

@@ -1,9 +1,10 @@
import { isEmpty, values } from 'ramda';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import { Link } from 'react-router-dom';
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 { getServerId, SelectedServer, ServersMap } from './data'; import { isEmpty, values } from 'ramda';
import { Link } from 'react-router-dom';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import type { SelectedServer, ServersMap } from './data';
import { getServerId } from './data';
export interface ServersDropdownProps { export interface ServersDropdownProps {
servers: ServersMap; servers: ServersMap;

View File

@@ -1,10 +1,10 @@
import { FC, PropsWithChildren } from 'react';
import { ListGroup, ListGroupItem } from 'reactstrap';
import { Link } from 'react-router-dom';
import classNames from 'classnames';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons'; import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
import { ServerWithId } from './data'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import type { FC, PropsWithChildren } from 'react';
import { Link } from 'react-router-dom';
import { ListGroup, ListGroupItem } from 'reactstrap';
import type { ServerWithId } from './data';
import './ServersListGroup.scss'; import './ServersListGroup.scss';
type ServersListGroupProps = PropsWithChildren<{ type ServersListGroupProps = PropsWithChildren<{

View File

@@ -1,5 +1,5 @@
import { omit } from 'ramda'; import { omit } from 'ramda';
import { SemVer } from '../../utils/helpers/version'; import type { SemVer } from '../../utils/helpers/version';
export interface ServerData { export interface ServerData {
name: string; name: string;

View File

@@ -1,6 +1,7 @@
import { FC, Fragment } from 'react'; import type { FC } from 'react';
import { Fragment } from 'react';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { ServerData } from '../data'; import type { ServerData } from '../data';
interface DuplicatedServersModalProps { interface DuplicatedServersModalProps {
duplicatedServers: ServerData[]; duplicatedServers: ServerData[];

View File

@@ -1,21 +1,30 @@
import { FC, PropsWithChildren } from 'react';
import { Card, CardText, CardTitle } from 'reactstrap';
import { Link } from 'react-router-dom';
import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons'; import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 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'; import './HighlightCard.scss';
export type HighlightCardProps = PropsWithChildren<{ export type HighlightCardProps = PropsWithChildren<{
title: string; title: string;
link?: string | false; link?: string;
tooltip?: ReactNode;
}>; }>;
const buildExtraProps = (link?: string | false) => (!link ? {} : { tag: Link, to: link }); const buildExtraProps = (link?: string) => (!link ? {} : { tag: Link, to: link });
export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link }) => ( export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link, tooltip }) => {
<Card className="highlight-card" body {...buildExtraProps(link)}> const ref = useElementRef<HTMLElement>();
{link && <FontAwesomeIcon size="3x" className="highlight-card__link-icon" icon={linkIcon} />}
<CardTitle tag="h5" className="highlight-card__title">{title}</CardTitle> return (
<CardText tag="h2">{children}</CardText> <>
</Card> <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>}
</>
);
};

View File

@@ -1,12 +1,12 @@
import { useRef, ChangeEvent, useState, useEffect, FC, PropsWithChildren } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap';
import { complement, pipe } from 'ramda';
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons'; import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '../../utils/helpers/hooks'; import { complement, pipe } from 'ramda';
import { mutableRefToElementRef } from '../../utils/helpers/components'; import type { ChangeEvent, FC, PropsWithChildren } from 'react';
import { ServersImporter } from '../services/ServersImporter'; import { useEffect, useState } from 'react';
import { ServerData, ServersMap } from '../data'; import { Button, UncontrolledTooltip } from 'reactstrap';
import { useElementRef, useToggle } from '../../utils/helpers/hooks';
import type { ServerData, ServersMap } from '../data';
import type { ServersImporter } from '../services/ServersImporter';
import { DuplicatedServersModal } from './DuplicatedServersModal'; import { DuplicatedServersModal } from './DuplicatedServersModal';
import './ImportServersBtn.scss'; import './ImportServersBtn.scss';
@@ -34,7 +34,7 @@ export const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC
tooltipPlacement = 'bottom', tooltipPlacement = 'bottom',
className = '', className = '',
}) => { }) => {
const ref = useRef<HTMLInputElement>(); const ref = useElementRef<HTMLInputElement>();
const [serversToCreate, setServersToCreate] = useState<ServerData[] | undefined>(); const [serversToCreate, setServersToCreate] = useState<ServerData[] | undefined>();
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]); const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
const [isModalOpen,, showModal, hideModal] = useToggle(); const [isModalOpen,, showModal, hideModal] = useToggle();
@@ -79,7 +79,7 @@ export const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC
type="file" type="file"
accept="text/csv" accept="text/csv"
className="import-servers-btn__csv-select" className="import-servers-btn__csv-select"
ref={mutableRefToElementRef(ref)} ref={ref}
onChange={onFile} onChange={onFile}
/> />

View File

@@ -1,10 +1,11 @@
import { FC } from 'react'; import type { FC } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Message } from '../../utils/Message';
import { ServersListGroup } from '../ServersListGroup';
import { DeleteServerButtonProps } from '../DeleteServerButton';
import { isServerWithId, SelectedServer, ServersMap } from '../data';
import { NoMenuLayout } from '../../common/NoMenuLayout'; import { NoMenuLayout } from '../../common/NoMenuLayout';
import { Message } from '../../utils/Message';
import type { SelectedServer, ServersMap } from '../data';
import { isServerWithId } from '../data';
import type { DeleteServerButtonProps } from '../DeleteServerButton';
import { ServersListGroup } from '../ServersListGroup';
import './ServerError.scss'; import './ServerError.scss';
interface ServerErrorProps { interface ServerErrorProps {

View File

@@ -1,8 +1,9 @@
import { FC, PropsWithChildren, ReactNode, useEffect, useState } from 'react'; import type { FC, PropsWithChildren, ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { InputFormGroup } from '../../utils/forms/InputFormGroup'; import { InputFormGroup } from '../../utils/forms/InputFormGroup';
import { handleEventPreventingDefault } from '../../utils/utils';
import { ServerData } from '../data';
import { SimpleCard } from '../../utils/SimpleCard'; import { SimpleCard } from '../../utils/SimpleCard';
import { handleEventPreventingDefault } from '../../utils/utils';
import type { ServerData } from '../data';
type ServerFormProps = PropsWithChildren<{ type ServerFormProps = PropsWithChildren<{
onSubmit: (server: ServerData) => void; onSubmit: (server: ServerData) => void;

View File

@@ -0,0 +1,26 @@
import type { FC } from 'react';
import { prettify } from '../../utils/helpers/numbers';
import type { PartialVisitsSummary } from '../../visits/reducers/visitsOverview';
import type { HighlightCardProps } from './HighlightCard';
import { HighlightCard } from './HighlightCard';
export type VisitsHighlightCardProps = Omit<HighlightCardProps, 'tooltip' | 'children'> & {
loading: boolean;
excludeBots: boolean;
visitsSummary: PartialVisitsSummary;
};
export const VisitsHighlightCard: FC<VisitsHighlightCardProps> = ({ loading, excludeBots, visitsSummary, ...rest }) => (
<HighlightCard
tooltip={
visitsSummary.bots !== undefined
? <>{excludeBots ? 'Plus' : 'Including'} <b>{prettify(visitsSummary.bots)}</b> potential bot visits</>
: undefined
}
{...rest}
>
{loading ? 'Loading...' : prettify(
excludeBots && visitsSummary.nonBots ? visitsSummary.nonBots : visitsSummary.total,
)}
</HighlightCard>
);

View File

@@ -1,8 +1,10 @@
import { FC, useEffect } from 'react'; import type { FC } from 'react';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Message } from '../../utils/Message';
import { isNotFoundServer, SelectedServer } from '../data';
import { NoMenuLayout } from '../../common/NoMenuLayout'; import { NoMenuLayout } from '../../common/NoMenuLayout';
import { Message } from '../../utils/Message';
import type { SelectedServer } from '../data';
import { isNotFoundServer } from '../data';
interface WithSelectedServerProps { interface WithSelectedServerProps {
selectServer: (serverId: string) => void; selectServer: (serverId: string) => void;

View File

@@ -1,4 +1,5 @@
import { FC, useEffect } from 'react'; import type { FC } from 'react';
import { useEffect } from 'react';
interface WithoutSelectedServerProps { interface WithoutSelectedServerProps {
resetSelectedServer: Function; resetSelectedServer: Function;

View File

@@ -1,8 +1,9 @@
import pack from '../../../package.json'; import pack from '../../../package.json';
import { hasServerData, ServerData } from '../data'; import type { HttpClient } from '../../common/services/HttpClient';
import { createServers } from './servers';
import { createAsyncThunk } from '../../utils/helpers/redux'; import { createAsyncThunk } from '../../utils/helpers/redux';
import { HttpClient } from '../../common/services/HttpClient'; import type { ServerData } from '../data';
import { hasServerData } from '../data';
import { createServers } from './servers';
const responseToServersList = (data: any): ServerData[] => (Array.isArray(data) ? data.filter(hasServerData) : []); const responseToServersList = (data: any): ServerData[] => (Array.isArray(data) ? data.filter(hasServerData) : []);

View File

@@ -1,10 +1,12 @@
import { createAction, createListenerMiddleware, createSlice, PayloadAction } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit';
import { createAction, createListenerMiddleware, createSlice } from '@reduxjs/toolkit';
import { memoizeWith, pipe } from 'ramda'; import { memoizeWith, pipe } from 'ramda';
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version'; import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { isReachableServer, SelectedServer, ServerWithId } from '../data'; import type { ShlinkHealth } from '../../api/types';
import { ShlinkHealth } from '../../api/types';
import { createAsyncThunk } from '../../utils/helpers/redux'; import { createAsyncThunk } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
import type { SelectedServer, ServerWithId } from '../data';
import { isReachableServer } from '../data';
const REDUCER_PREFIX = 'shlink/selectedServer'; const REDUCER_PREFIX = 'shlink/selectedServer';

View File

@@ -1,7 +1,8 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { assoc, dissoc, fromPairs, map, pipe, reduce, toPairs } from 'ramda'; import { assoc, dissoc, fromPairs, map, pipe, reduce, toPairs } from 'ramda';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { ServerData, ServersMap, ServerWithId } from '../data'; import type { ServerData, ServersMap, ServerWithId } from '../data';
interface EditServer { interface EditServer {
serverId: string; serverId: string;

View File

@@ -1,12 +1,13 @@
import { values } from 'ramda'; import { values } from 'ramda';
import { LocalStorage } from '../../utils/services/LocalStorage'; import type { JsonToCsv } from '../../utils/helpers/csvjson';
import { ServersMap, serverWithIdToServerData } from '../data';
import { saveCsv } from '../../utils/helpers/files'; import { saveCsv } from '../../utils/helpers/files';
import { JsonToCsv } from '../../utils/helpers/csvjson'; import type { LocalStorage } from '../../utils/services/LocalStorage';
import type { ServersMap } from '../data';
import { serverWithIdToServerData } from '../data';
const SERVERS_FILENAME = 'shlink-servers.csv'; const SERVERS_FILENAME = 'shlink-servers.csv';
export default class ServersExporter { export class ServersExporter {
public constructor( public constructor(
private readonly storage: LocalStorage, private readonly storage: LocalStorage,
private readonly window: Window, private readonly window: Window,

View File

@@ -1,5 +1,5 @@
import { ServerData } from '../data'; import type { CsvToJson } from '../../utils/helpers/csvjson';
import { CsvToJson } from '../../utils/helpers/csvjson'; import type { ServerData } from '../data';
const validateServer = (server: any): server is ServerData => const validateServer = (server: any): server is ServerData =>
typeof server.url === 'string' && typeof server.apiKey === 'string' && typeof server.name === 'string'; typeof server.url === 'string' && typeof server.apiKey === 'string' && typeof server.name === 'string';

View File

@@ -1,11 +1,18 @@
import type Bottle from 'bottlejs';
import { prop } from 'ramda'; import { prop } from 'ramda';
import Bottle from 'bottlejs'; import type { ConnectDecorator } from '../../container/types';
import { CreateServer } from '../CreateServer'; import { CreateServer } from '../CreateServer';
import { ServersDropdown } from '../ServersDropdown';
import { DeleteServerModal } from '../DeleteServerModal';
import { DeleteServerButton } from '../DeleteServerButton'; import { DeleteServerButton } from '../DeleteServerButton';
import { DeleteServerModal } from '../DeleteServerModal';
import { EditServer } from '../EditServer'; import { EditServer } from '../EditServer';
import { ImportServersBtn } from '../helpers/ImportServersBtn'; import { ImportServersBtn } from '../helpers/ImportServersBtn';
import { ServerError } from '../helpers/ServerError';
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
import { ManageServers } from '../ManageServers';
import { ManageServersRow } from '../ManageServersRow';
import { ManageServersRowDropdown } from '../ManageServersRowDropdown';
import { Overview } from '../Overview';
import { fetchServers } from '../reducers/remoteServers';
import { import {
resetSelectedServer, resetSelectedServer,
selectedServerReducerCreator, selectedServerReducerCreator,
@@ -13,18 +20,11 @@ import {
selectServerListener, selectServerListener,
} from '../reducers/selectedServer'; } from '../reducers/selectedServer';
import { createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers'; import { createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
import { fetchServers } from '../reducers/remoteServers'; import { ServersDropdown } from '../ServersDropdown';
import { ServerError } from '../helpers/ServerError'; import { ServersExporter } from './ServersExporter';
import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
import { Overview } from '../Overview';
import { ManageServers } from '../ManageServers';
import { ManageServersRow } from '../ManageServersRow';
import { ManageServersRowDropdown } from '../ManageServersRowDropdown';
import { ServersImporter } from './ServersImporter'; import { ServersImporter } from './ServersImporter';
import ServersExporter from './ServersExporter';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory( bottle.serviceFactory(
'ManageServers', 'ManageServers',
@@ -65,7 +65,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl'); bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl');
bottle.decorator('Overview', connect( bottle.decorator('Overview', connect(
['shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview'], ['shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview', 'settings'],
['listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview'], ['listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview'],
)); ));
@@ -89,5 +89,3 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('selectedServerReducerCreator', selectedServerReducerCreator, 'selectServer'); bottle.serviceFactory('selectedServerReducerCreator', selectedServerReducerCreator, 'selectServer');
bottle.serviceFactory('selectedServerReducer', prop('reducer'), 'selectedServerReducerCreator'); bottle.serviceFactory('selectedServerReducer', prop('reducer'), 'selectedServerReducerCreator');
}; };
export default provideServices;

View File

@@ -1,11 +1,11 @@
import { FormGroup, Input } from 'reactstrap';
import classNames from 'classnames'; import classNames from 'classnames';
import { ToggleSwitch } from '../utils/ToggleSwitch'; import { FormGroup, Input } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
import { FormText } from '../utils/forms/FormText'; import { FormText } from '../utils/forms/FormText';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup'; import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings } from './reducers/settings';
import { useDomId } from '../utils/helpers/hooks'; import { useDomId } from '../utils/helpers/hooks';
import { SimpleCard } from '../utils/SimpleCard';
import { ToggleSwitch } from '../utils/ToggleSwitch';
import type { Settings } from './reducers/settings';
interface RealTimeUpdatesProps { interface RealTimeUpdatesProps {
settings: Settings; settings: Settings;

View File

@@ -1,5 +1,5 @@
import { FC, ReactNode } from 'react'; import type { FC, ReactNode } from 'react';
import { Navigate, Routes, Route } from 'react-router-dom'; import { Navigate, Route, Routes } from 'react-router-dom';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import { NavPillItem, NavPills } from '../utils/NavPills'; import { NavPillItem, NavPills } from '../utils/NavPills';

View File

@@ -1,11 +1,11 @@
import { FC, ReactNode } from 'react'; import type { FC, ReactNode } from 'react';
import { DropdownItem, FormGroup } from 'reactstrap'; import { DropdownItem, FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
import { ToggleSwitch } from '../utils/ToggleSwitch';
import { DropdownBtn } from '../utils/DropdownBtn'; import { DropdownBtn } from '../utils/DropdownBtn';
import { FormText } from '../utils/forms/FormText'; import { FormText } from '../utils/forms/FormText';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup'; import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings, ShortUrlCreationSettings as ShortUrlsSettings, TagFilteringMode } from './reducers/settings'; import { SimpleCard } from '../utils/SimpleCard';
import { ToggleSwitch } from '../utils/ToggleSwitch';
import type { Settings, ShortUrlCreationSettings as ShortUrlsSettings, TagFilteringMode } from './reducers/settings';
interface ShortUrlCreationProps { interface ShortUrlCreationProps {
settings: Settings; settings: Settings;

View File

@@ -1,9 +1,10 @@
import { FC } from 'react'; import type { FC } from 'react';
import { OrderingDropdown } from '../utils/OrderingDropdown';
import { SHORT_URLS_ORDERABLE_FIELDS } from '../short-urls/data'; import { SHORT_URLS_ORDERABLE_FIELDS } from '../short-urls/data';
import { SimpleCard } from '../utils/SimpleCard';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup'; import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings as ShortUrlsSettings } from './reducers/settings'; import { OrderingDropdown } from '../utils/OrderingDropdown';
import { SimpleCard } from '../utils/SimpleCard';
import type { Settings, ShortUrlsListSettings as ShortUrlsSettings } from './reducers/settings';
import { DEFAULT_SHORT_URLS_ORDERING } from './reducers/settings';
interface ShortUrlsListSettingsProps { interface ShortUrlsListSettingsProps {
settings: Settings; settings: Settings;

View File

@@ -1,9 +1,9 @@
import { FC } from 'react'; import type { FC } from 'react';
import { SimpleCard } from '../utils/SimpleCard';
import { OrderingDropdown } from '../utils/OrderingDropdown';
import { TAGS_ORDERABLE_FIELDS } from '../tags/data/TagsListChildrenProps'; import { TAGS_ORDERABLE_FIELDS } from '../tags/data/TagsListChildrenProps';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup'; import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings, TagsSettings as TagsSettingsOptions } from './reducers/settings'; import { OrderingDropdown } from '../utils/OrderingDropdown';
import { SimpleCard } from '../utils/SimpleCard';
import type { Settings, TagsSettings as TagsSettingsOptions } from './reducers/settings';
interface TagsProps { interface TagsProps {
settings: Settings; settings: Settings;

View File

@@ -1,10 +1,11 @@
import { FC } from 'react'; import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons'; import type { FC } from 'react';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
import type { Theme } from '../utils/theme';
import { changeThemeInMarkup } from '../utils/theme';
import { ToggleSwitch } from '../utils/ToggleSwitch'; import { ToggleSwitch } from '../utils/ToggleSwitch';
import { changeThemeInMarkup, Theme } from '../utils/theme'; import type { Settings, UiSettings } from './reducers/settings';
import { Settings, UiSettings } from './reducers/settings';
import './UserInterfaceSettings.scss'; import './UserInterfaceSettings.scss';
interface UserInterfaceProps { interface UserInterfaceProps {

View File

@@ -1,12 +1,12 @@
import { FC } from 'react'; import type { FC } from 'react';
import { FormGroup } from 'reactstrap'; import { FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector'; import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings, VisitsSettings as VisitsSettingsConfig } from './reducers/settings';
import { ToggleSwitch } from '../utils/ToggleSwitch';
import { FormText } from '../utils/forms/FormText'; import { FormText } from '../utils/forms/FormText';
import { DateInterval } from '../utils/helpers/dateIntervals'; import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import type { DateInterval } from '../utils/helpers/dateIntervals';
import { SimpleCard } from '../utils/SimpleCard';
import { ToggleSwitch } from '../utils/ToggleSwitch';
import type { Settings, VisitsSettings as VisitsSettingsConfig } from './reducers/settings';
interface VisitsProps { interface VisitsProps {
settings: Settings; settings: Settings;

View File

@@ -1,4 +1,4 @@
import { ShlinkState } from '../../container/types'; import type { ShlinkState } from '../../container/types';
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
export const migrateDeprecatedSettings = (state: Partial<ShlinkState>): Partial<ShlinkState> => { export const migrateDeprecatedSettings = (state: Partial<ShlinkState>): Partial<ShlinkState> => {

View File

@@ -1,9 +1,10 @@
import { createSlice, PayloadAction, PrepareAction } from '@reduxjs/toolkit'; import type { PayloadAction, PrepareAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { mergeDeepRight } from 'ramda'; import { mergeDeepRight } from 'ramda';
import { Theme } from '../../utils/theme'; import type { ShortUrlsOrder } from '../../short-urls/data';
import { DateInterval } from '../../utils/helpers/dateIntervals'; import type { TagsOrder } from '../../tags/data/TagsListChildrenProps';
import { TagsOrder } from '../../tags/data/TagsListChildrenProps'; import type { DateInterval } from '../../utils/helpers/dateIntervals';
import { ShortUrlsOrder } from '../../short-urls/data'; import type { Theme } from '../../utils/theme';
export const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = { export const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = {
field: 'dateCreated', field: 'dateCreated',

View File

@@ -1,6 +1,7 @@
import Bottle from 'bottlejs'; import type Bottle from 'bottlejs';
import type { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { RealTimeUpdatesSettings } from '../RealTimeUpdatesSettings'; import { RealTimeUpdatesSettings } from '../RealTimeUpdatesSettings';
import { Settings } from '../Settings';
import { import {
setRealTimeUpdatesInterval, setRealTimeUpdatesInterval,
setShortUrlCreationSettings, setShortUrlCreationSettings,
@@ -10,15 +11,14 @@ import {
setVisitsSettings, setVisitsSettings,
toggleRealTimeUpdates, toggleRealTimeUpdates,
} from '../reducers/settings'; } from '../reducers/settings';
import { ConnectDecorator } from '../../container/types'; import { Settings } from '../Settings';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { ShortUrlCreationSettings } from '../ShortUrlCreationSettings'; import { ShortUrlCreationSettings } from '../ShortUrlCreationSettings';
import { ShortUrlsListSettings } from '../ShortUrlsListSettings';
import { TagsSettings } from '../TagsSettings';
import { UserInterfaceSettings } from '../UserInterfaceSettings'; import { UserInterfaceSettings } from '../UserInterfaceSettings';
import { VisitsSettings } from '../VisitsSettings'; import { VisitsSettings } from '../VisitsSettings';
import { TagsSettings } from '../TagsSettings';
import { ShortUrlsListSettings } from '../ShortUrlsListSettings';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory( bottle.serviceFactory(
'Settings', 'Settings',
@@ -63,5 +63,3 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('setVisitsSettings', () => setVisitsSettings); bottle.serviceFactory('setVisitsSettings', () => setVisitsSettings);
bottle.serviceFactory('setTagsSettings', () => setTagsSettings); bottle.serviceFactory('setTagsSettings', () => setTagsSettings);
}; };
export default provideServices;

View File

@@ -1,10 +1,11 @@
import { FC, useMemo } from 'react'; import type { FC } from 'react';
import { SelectedServer } from '../servers/data'; import { useMemo } from 'react';
import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings'; import type { SelectedServer } from '../servers/data';
import { ShortUrlData } from './data'; import type { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings';
import { ShortUrlCreation } from './reducers/shortUrlCreation'; import type { ShortUrlData } from './data';
import { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult'; import type { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
import { ShortUrlFormProps } from './ShortUrlForm'; import type { ShortUrlCreation } from './reducers/shortUrlCreation';
import type { ShortUrlFormProps } from './ShortUrlForm';
export interface CreateShortUrlProps { export interface CreateShortUrlProps {
basicMode?: boolean; basicMode?: boolean;

View File

@@ -1,21 +1,22 @@
import { FC, useEffect, useMemo } from 'react';
import { Button, Card } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react';
import { useEffect, useMemo } from 'react';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { useLocation, useParams } from 'react-router-dom'; import { useLocation, useParams } from 'react-router-dom';
import { SelectedServer } from '../servers/data'; import { Button, Card } from 'reactstrap';
import { Settings } from '../settings/reducers/settings'; import { ShlinkApiError } from '../api/ShlinkApiError';
import { ShortUrlIdentifier } from './data'; import type { SelectedServer } from '../servers/data';
import type { Settings } from '../settings/reducers/settings';
import { useGoBack } from '../utils/helpers/hooks';
import { parseQuery } from '../utils/helpers/query'; import { parseQuery } from '../utils/helpers/query';
import { Message } from '../utils/Message'; import { Message } from '../utils/Message';
import { Result } from '../utils/Result'; import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError'; import type { ShortUrlIdentifier } from './data';
import { useGoBack } from '../utils/helpers/hooks';
import { ShortUrlFormProps } from './ShortUrlForm';
import { ShortUrlDetail } from './reducers/shortUrlDetail';
import { EditShortUrl as EditShortUrlInfo, ShortUrlEdition } from './reducers/shortUrlEdition';
import { shortUrlDataFromShortUrl, urlDecodeShortCode } from './helpers'; import { shortUrlDataFromShortUrl, urlDecodeShortCode } from './helpers';
import type { ShortUrlDetail } from './reducers/shortUrlDetail';
import type { EditShortUrl as EditShortUrlInfo, ShortUrlEdition } from './reducers/shortUrlEdition';
import type { ShortUrlFormProps } from './ShortUrlForm';
interface EditShortUrlConnectProps { interface EditShortUrlConnectProps {
settings: Settings; settings: Settings;

View File

@@ -1,13 +1,14 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap'; import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import type { ShlinkPaginator } from '../api/types';
import type {
NumberOrEllipsis } from '../utils/helpers/pagination';
import { import {
pageIsEllipsis,
keyForPage, keyForPage,
progressivePagination, pageIsEllipsis,
prettifyPageNumber, prettifyPageNumber,
NumberOrEllipsis, progressivePagination,
} from '../utils/helpers/pagination'; } from '../utils/helpers/pagination';
import { ShlinkPaginator } from '../api/types';
interface PaginatorProps { interface PaginatorProps {
paginator?: ShlinkPaginator; paginator?: ShlinkPaginator;

View File

@@ -1,20 +1,27 @@
import { FC, useEffect, useState } from 'react'; import type { IconProp } from '@fortawesome/fontawesome-svg-core';
import { InputType } from 'reactstrap/types/lib/Input'; import { faAndroid, faApple } from '@fortawesome/free-brands-svg-icons';
import { Button, FormGroup, Input, Row } from 'reactstrap'; import { faDesktop } from '@fortawesome/free-solid-svg-icons';
import { cond, isEmpty, pipe, replace, trim, T } from 'ramda'; import classNames from 'classnames';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { DateTimeInput, DateTimeInputProps } from '../utils/dates/DateTimeInput'; import { isEmpty, pipe, replace, trim } from 'ramda';
import { supportsForwardQuery } from '../utils/helpers/features'; import type { ChangeEvent, FC } from 'react';
import { SimpleCard } from '../utils/SimpleCard'; import { useEffect, useState } from 'react';
import { handleEventPreventingDefault, hasValue, OptionalString } from '../utils/utils'; import { Button, FormGroup, Input, Row } from 'reactstrap';
import type { InputType } from 'reactstrap/types/lib/Input';
import type { DomainSelectorProps } from '../domains/DomainSelector';
import type { SelectedServer } from '../servers/data';
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import { Checkbox } from '../utils/Checkbox'; import { Checkbox } from '../utils/Checkbox';
import { SelectedServer } from '../servers/data'; import type { DateTimeInputProps } from '../utils/dates/DateTimeInput';
import { TagsSelectorProps } from '../tags/helpers/TagsSelector'; import { DateTimeInput } from '../utils/dates/DateTimeInput';
import { DomainSelectorProps } from '../domains/DomainSelector';
import { formatIsoDate } from '../utils/helpers/date'; import { formatIsoDate } from '../utils/helpers/date';
import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon'; import { useFeature } from '../utils/helpers/features';
import { ShortUrlData } from './data'; import { IconInput } from '../utils/IconInput';
import { SimpleCard } from '../utils/SimpleCard';
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
import type { DeviceLongUrls, ShortUrlData } from './data';
import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup'; import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup';
import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon';
import './ShortUrlForm.scss'; import './ShortUrlForm.scss';
export type Mode = 'create' | 'create-basic' | 'edit'; export type Mode = 'create' | 'create-basic' | 'edit';
@@ -38,38 +45,38 @@ export const ShortUrlForm = (
DomainSelector: FC<DomainSelectorProps>, DomainSelector: FC<DomainSelectorProps>,
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => { ): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => {
const [shortUrlData, setShortUrlData] = useState(initialState); const [shortUrlData, setShortUrlData] = useState(initialState);
const reset = () => setShortUrlData(initialState);
const supportsDeviceLongUrls = useFeature('deviceLongUrls', selectedServer);
const isEdit = mode === 'edit'; const isEdit = mode === 'edit';
const isBasicMode = mode === 'create-basic'; const isBasicMode = mode === 'create-basic';
const hadTitleOriginally = hasValue(initialState.title);
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) }); const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
const reset = () => setShortUrlData(initialState); const setResettableValue = (value: string, initialValue?: any) => {
const resolveNewTitle = (): OptionalString => { if (hasValue(value)) {
const hasNewTitle = hasValue(shortUrlData.title); return value;
const matcher = cond<never, OptionalString>([ }
[() => !hasNewTitle && !hadTitleOriginally, () => undefined],
[() => !hasNewTitle && hadTitleOriginally, () => null],
[T, () => shortUrlData.title],
]);
return matcher(); // If an initial value was provided for this when the input is "emptied", explicitly set it to null so that the
// value gets removed. Otherwise, set undefined so that it gets ignored.
return hasValue(initialValue) ? null : undefined;
}; };
const submit = handleEventPreventingDefault(async () => onSave({ const submit = handleEventPreventingDefault(async () => onSave({
...shortUrlData, ...shortUrlData,
validSince: formatIsoDate(shortUrlData.validSince) ?? null, validSince: formatIsoDate(shortUrlData.validSince) ?? null,
validUntil: formatIsoDate(shortUrlData.validUntil) ?? null, validUntil: formatIsoDate(shortUrlData.validUntil) ?? null,
maxVisits: !hasValue(shortUrlData.maxVisits) ? null : Number(shortUrlData.maxVisits), maxVisits: !hasValue(shortUrlData.maxVisits) ? null : Number(shortUrlData.maxVisits),
title: resolveNewTitle(),
}).then(() => !isEdit && reset()).catch(() => {})); }).then(() => !isEdit && reset()).catch(() => {}));
useEffect(() => { useEffect(() => {
setShortUrlData(initialState); setShortUrlData(initialState);
}, [initialState]); }, [initialState]);
// TODO Consider extracting these functions to local components
const renderOptionalInput = ( const renderOptionalInput = (
id: NonDateFields, id: NonDateFields,
placeholder: string, placeholder: string,
type: InputType = 'text', type: InputType = 'text',
props = {}, props: any = {},
fromGroupProps = {}, fromGroupProps = {},
) => ( ) => (
<FormGroup {...fromGroupProps}> <FormGroup {...fromGroupProps}>
@@ -78,11 +85,27 @@ export const ShortUrlForm = (
type={type} type={type}
placeholder={placeholder} placeholder={placeholder}
value={shortUrlData[id] ?? ''} value={shortUrlData[id] ?? ''}
onChange={(e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value })} onChange={props.onChange ?? ((e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value }))}
{...props} {...props}
/> />
</FormGroup> </FormGroup>
); );
const renderDeviceLongUrlInput = (id: keyof DeviceLongUrls, placeholder: string, icon: IconProp) => (
<IconInput
icon={icon}
id={id}
type="url"
placeholder={placeholder}
value={shortUrlData.deviceLongUrls?.[id] ?? ''}
onChange={(e) => setShortUrlData({
...shortUrlData,
deviceLongUrls: {
...(shortUrlData.deviceLongUrls ?? {}),
[id]: setResettableValue(e.target.value, initialState.deviceLongUrls?.[id]),
},
})}
/>
);
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateTimeInputProps> = {}) => ( const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateTimeInputProps> = {}) => (
<DateTimeInput <DateTimeInput
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null} selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
@@ -113,21 +136,45 @@ export const ShortUrlForm = (
</> </>
); );
const showForwardQueryControl = supportsForwardQuery(selectedServer); const showForwardQueryControl = useFeature('forwardQuery', selectedServer);
return ( return (
<form name="shortUrlForm" className="short-url-form" onSubmit={submit}> <form name="shortUrlForm" className="short-url-form" onSubmit={submit}>
{isBasicMode && basicComponents} {isBasicMode && basicComponents}
{!isBasicMode && ( {!isBasicMode && (
<> <>
<SimpleCard title="Main options" className="mb-3"> <Row>
{basicComponents} <div
</SimpleCard> className={classNames('mb-3', { 'col-sm-6': supportsDeviceLongUrls, 'col-12': !supportsDeviceLongUrls })}
>
<SimpleCard title="Main options" className="mb-3">
{basicComponents}
</SimpleCard>
</div>
{supportsDeviceLongUrls && (
<div className="col-sm-6 mb-3">
<SimpleCard title="Device-specific long URLs">
<FormGroup>
{renderDeviceLongUrlInput('android', 'Android-specific redirection', faAndroid)}
</FormGroup>
<FormGroup>
{renderDeviceLongUrlInput('ios', 'iOS-specific redirection', faApple)}
</FormGroup>
{renderDeviceLongUrlInput('desktop', 'Desktop-specific redirection', faDesktop)}
</SimpleCard>
</div>
)}
</Row>
<Row> <Row>
<div className="col-sm-6 mb-3"> <div className="col-sm-6 mb-3">
<SimpleCard title="Customize the short URL"> <SimpleCard title="Customize the short URL">
{renderOptionalInput('title', 'Title')} {renderOptionalInput('title', 'Title', 'text', {
onChange: ({ target }: ChangeEvent<HTMLInputElement>) => setShortUrlData({
...shortUrlData,
title: setResettableValue(target.value, initialState.title),
}),
})}
{!isEdit && ( {!isEdit && (
<> <>
<Row> <Row>

View File

@@ -1,23 +1,25 @@
import { FC } from 'react';
import { isEmpty, pipe } from 'ramda';
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTag, faTags } from '@fortawesome/free-solid-svg-icons'; import { faTag, faTags } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames'; import classNames from 'classnames';
import { SearchField } from '../utils/SearchField'; import { isEmpty, pipe } from 'ramda';
import type { FC } from 'react';
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
import type { SelectedServer } from '../servers/data';
import type { Settings } from '../settings/reducers/settings';
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
import { formatIsoDate } from '../utils/helpers/date'; import { formatIsoDate } from '../utils/helpers/date';
import { DateRange, datesToDateRange } from '../utils/helpers/dateIntervals'; import type { DateRange } from '../utils/helpers/dateIntervals';
import { supportsAllTagsFiltering, supportsFilterDisabledUrls } from '../utils/helpers/features'; import { datesToDateRange } from '../utils/helpers/dateIntervals';
import { SelectedServer } from '../servers/data'; import { useFeature } from '../utils/helpers/features';
import { OrderDir } from '../utils/helpers/ordering'; import type { OrderDir } from '../utils/helpers/ordering';
import { OrderingDropdown } from '../utils/OrderingDropdown'; import { OrderingDropdown } from '../utils/OrderingDropdown';
import { SearchField } from '../utils/SearchField';
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { SHORT_URLS_ORDERABLE_FIELDS } from './data';
import type { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
import { useShortUrlsQuery } from './helpers/hooks'; import { useShortUrlsQuery } from './helpers/hooks';
import { SHORT_URLS_ORDERABLE_FIELDS, ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import { ShortUrlsFilterDropdown } from './helpers/ShortUrlsFilterDropdown'; import { ShortUrlsFilterDropdown } from './helpers/ShortUrlsFilterDropdown';
import { Settings } from '../settings/reducers/settings';
import './ShortUrlsFilteringBar.scss'; import './ShortUrlsFilteringBar.scss';
interface ShortUrlsFilteringProps { interface ShortUrlsFilteringProps {
@@ -44,7 +46,7 @@ export const ShortUrlsFilteringBar = (
excludePastValidUntil, excludePastValidUntil,
tagsMode = 'any', tagsMode = 'any',
} = filter; } = filter;
const supportsDisabledFiltering = supportsFilterDisabledUrls(selectedServer); const supportsDisabledFiltering = useFeature('filterDisabledUrls', selectedServer);
const setDates = pipe( const setDates = pipe(
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({ ({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
@@ -58,7 +60,7 @@ export const ShortUrlsFilteringBar = (
(searchTerm) => toFirstPage({ search: searchTerm }), (searchTerm) => toFirstPage({ search: searchTerm }),
); );
const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags }); const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags });
const canChangeTagsMode = supportsAllTagsFiltering(selectedServer); const canChangeTagsMode = useFeature('allTagsFiltering', selectedServer);
const toggleTagsMode = pipe( const toggleTagsMode = pipe(
() => (tagsMode === 'any' ? 'all' : 'any'), () => (tagsMode === 'any' ? 'all' : 'any'),
(mode) => toFirstPage({ tagsMode: mode }), (mode) => toFirstPage({ tagsMode: mode }),

View File

@@ -1,21 +1,24 @@
import { pipe } from 'ramda'; import { pipe } from 'ramda';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Card } from 'reactstrap';
import { useLocation, useParams } from 'react-router-dom'; import { useLocation, useParams } from 'react-router-dom';
import { determineOrderDir, OrderDir } from '../utils/helpers/ordering'; import { Card } from 'reactstrap';
import { getServerId, SelectedServer } from '../servers/data'; import type { ShlinkShortUrlsListParams, ShlinkShortUrlsOrder } from '../api/types';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import type { SelectedServer } from '../servers/data';
import { getServerId } from '../servers/data';
import type { Settings } from '../settings/reducers/settings';
import { DEFAULT_SHORT_URLS_ORDERING } from '../settings/reducers/settings';
import { useFeature } from '../utils/helpers/features';
import type { OrderDir } from '../utils/helpers/ordering';
import { determineOrderDir } from '../utils/helpers/ordering';
import { TableOrderIcon } from '../utils/table/TableOrderIcon'; import { TableOrderIcon } from '../utils/table/TableOrderIcon';
import { ShlinkShortUrlsListParams, ShlinkShortUrlsOrder } from '../api/types'; import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings';
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { ShortUrlsTableType } from './ShortUrlsTable';
import { Paginator } from './Paginator';
import { useShortUrlsQuery } from './helpers/hooks'; import { useShortUrlsQuery } from './helpers/hooks';
import { ShortUrlsOrder, ShortUrlsOrderableFields } from './data'; import { Paginator } from './Paginator';
import { ShortUrlsFilteringBarType } from './ShortUrlsFilteringBar'; import type { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { supportsExcludeBotsOnShortUrls } from '../utils/helpers/features'; import type { ShortUrlsFilteringBarType } from './ShortUrlsFilteringBar';
import type { ShortUrlsTableType } from './ShortUrlsTable';
interface ShortUrlsListProps { interface ShortUrlsListProps {
selectedServer: SelectedServer; selectedServer: SelectedServer;
@@ -49,6 +52,7 @@ export const ShortUrlsList = (
); );
const { pagination } = shortUrlsList?.shortUrls ?? {}; const { pagination } = shortUrlsList?.shortUrls ?? {};
const doExcludeBots = excludeBots ?? settings.visits?.excludeBots; const doExcludeBots = excludeBots ?? settings.visits?.excludeBots;
const supportsExcludingBots = useFeature('excludeBotsOnShortUrls', selectedServer);
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => { const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
toFirstPage({ orderBy: { field, dir } }); toFirstPage({ orderBy: { field, dir } });
setActualOrderBy({ field, dir }); setActualOrderBy({ field, dir });
@@ -62,7 +66,7 @@ export const ShortUrlsList = (
(updatedTags) => toFirstPage({ tags: updatedTags }), (updatedTags) => toFirstPage({ tags: updatedTags }),
); );
const parseOrderByForShlink = ({ field, dir }: ShortUrlsOrder): ShlinkShortUrlsOrder => { const parseOrderByForShlink = ({ field, dir }: ShortUrlsOrder): ShlinkShortUrlsOrder => {
if (supportsExcludeBotsOnShortUrls(selectedServer) && doExcludeBots && field === 'visits') { if (supportsExcludingBots && doExcludeBots && field === 'visits') {
return { field: 'nonBotVisits', dir }; return { field: 'nonBotVisits', dir };
} }

View File

@@ -1,10 +1,10 @@
import { ReactNode } from 'react';
import { isEmpty } from 'ramda';
import classNames from 'classnames'; import classNames from 'classnames';
import { SelectedServer } from '../servers/data'; import { isEmpty } from 'ramda';
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; import type { ReactNode } from 'react';
import { ShortUrlsRowType } from './helpers/ShortUrlsRow'; import type { SelectedServer } from '../servers/data';
import { ShortUrlsOrderableFields } from './data'; import type { ShortUrlsOrderableFields } from './data';
import type { ShortUrlsRowType } from './helpers/ShortUrlsRow';
import type { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import './ShortUrlsTable.scss'; import './ShortUrlsTable.scss';
interface ShortUrlsTableProps { interface ShortUrlsTableProps {

View File

@@ -1,5 +1,5 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Modal, ModalBody, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalHeader } from 'reactstrap';
import { useToggle } from '../utils/helpers/hooks'; import { useToggle } from '../utils/helpers/hooks';
import './UseExistingIfFoundInfoIcon.scss'; import './UseExistingIfFoundInfoIcon.scss';

View File

@@ -1,8 +1,16 @@
import { Nullable, OptionalString } from '../../utils/utils'; import type { ShlinkVisitsSummary } from '../../api/types';
import { Order } from '../../utils/helpers/ordering'; import type { Order } from '../../utils/helpers/ordering';
import type { Nullable, OptionalString } from '../../utils/utils';
export interface DeviceLongUrls {
android?: OptionalString;
ios?: OptionalString;
desktop?: OptionalString;
}
export interface EditShortUrlData { export interface EditShortUrlData {
longUrl?: string; longUrl?: string;
deviceLongUrls?: DeviceLongUrls;
tags?: string[]; tags?: string[];
title?: string | null; title?: string | null;
validSince?: Date | string | null; validSince?: Date | string | null;
@@ -30,10 +38,11 @@ export interface ShortUrl {
shortCode: string; shortCode: string;
shortUrl: string; shortUrl: string;
longUrl: string; longUrl: string;
deviceLongUrls?: Required<DeviceLongUrls>, // Optional only before Shlink 3.5.0
dateCreated: string; dateCreated: string;
/** @deprecated */ /** @deprecated */
visitsCount: number; // Deprecated since Shlink 3.4.0 visitsCount: number; // Deprecated since Shlink 3.4.0
visitsSummary?: ShortUrlVisitsSummary; // Optional only before Shlink 3.4.0 visitsSummary?: ShlinkVisitsSummary; // Optional only before Shlink 3.4.0
meta: Required<Nullable<ShortUrlMeta>>; meta: Required<Nullable<ShortUrlMeta>>;
tags: string[]; tags: string[];
domain: string | null; domain: string | null;
@@ -48,12 +57,6 @@ export interface ShortUrlMeta {
maxVisits?: number; maxVisits?: number;
} }
export interface ShortUrlVisitsSummary {
total: number;
nonBots: number;
bots: number;
}
export interface ShortUrlModalProps { export interface ShortUrlModalProps {
shortUrl: ShortUrl; shortUrl: ShortUrl;
isOpen: boolean; isOpen: boolean;

View File

@@ -1,14 +1,14 @@
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons'; import { faClone as copyIcon } from '@fortawesome/free-regular-svg-icons';
import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons'; import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useEffect } from 'react'; import { useEffect } from 'react';
import CopyToClipboard from 'react-copy-to-clipboard'; import CopyToClipboard from 'react-copy-to-clipboard';
import { Tooltip } from 'reactstrap'; import { Tooltip } from 'reactstrap';
import { ShortUrlCreation } from '../reducers/shortUrlCreation';
import { TimeoutToggle } from '../../utils/helpers/hooks';
import { Result } from '../../utils/Result';
import './CreateShortUrlResult.scss';
import { ShlinkApiError } from '../../api/ShlinkApiError'; import { ShlinkApiError } from '../../api/ShlinkApiError';
import type { TimeoutToggle } from '../../utils/helpers/hooks';
import { Result } from '../../utils/Result';
import type { ShortUrlCreation } from '../reducers/shortUrlCreation';
import './CreateShortUrlResult.scss';
export interface CreateShortUrlResultProps { export interface CreateShortUrlResultProps {
creation: ShortUrlCreation; creation: ShortUrlCreation;

View File

@@ -1,12 +1,12 @@
import { pipe } from 'ramda';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { pipe } from 'ramda';
import { ShortUrlDeletion } from '../reducers/shortUrlDeletion';
import { ShortUrlIdentifier, ShortUrlModalProps } from '../data';
import { handleEventPreventingDefault } from '../../utils/utils';
import { Result } from '../../utils/Result';
import { isInvalidDeletionError } from '../../api/utils';
import { ShlinkApiError } from '../../api/ShlinkApiError'; import { ShlinkApiError } from '../../api/ShlinkApiError';
import { isInvalidDeletionError } from '../../api/utils';
import { Result } from '../../utils/Result';
import { handleEventPreventingDefault } from '../../utils/utils';
import type { ShortUrlIdentifier, ShortUrlModalProps } from '../data';
import type { ShortUrlDeletion } from '../reducers/shortUrlDeletion';
interface DeleteShortUrlModalConnectProps extends ShortUrlModalProps { interface DeleteShortUrlModalConnectProps extends ShortUrlModalProps {
shortUrlDeletion: ShortUrlDeletion; shortUrlDeletion: ShortUrlDeletion;

View File

@@ -1,10 +1,11 @@
import { FC } from 'react'; import type { FC } from 'react';
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import type { ReportExporter } from '../../common/services/ReportExporter';
import type { SelectedServer } from '../../servers/data';
import { isServerWithId } from '../../servers/data';
import { ExportBtn } from '../../utils/ExportBtn'; import { ExportBtn } from '../../utils/ExportBtn';
import { useToggle } from '../../utils/helpers/hooks'; import { useToggle } from '../../utils/helpers/hooks';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import type { ShortUrl } from '../data';
import { isServerWithId, SelectedServer } from '../../servers/data';
import { ShortUrl } from '../data';
import { ReportExporter } from '../../common/services/ReportExporter';
import { useShortUrlsQuery } from './hooks'; import { useShortUrlsQuery } from './hooks';
export interface ExportShortUrlsBtnProps { export interface ExportShortUrlsBtnProps {

Some files were not shown because too many files have changed in this diff Show More