mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-25 03:06:36 +00:00
Compare commits
202 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e58d861ec | ||
|
|
2d8c2f92c4 | ||
|
|
56fa114f3c | ||
|
|
0a57390c46 | ||
|
|
ea7345b872 | ||
|
|
e44520b2c2 | ||
|
|
92ddcad753 | ||
|
|
e632c5b04f | ||
|
|
47d30aaa34 | ||
|
|
a26019ca78 | ||
|
|
ef8db5e2cd | ||
|
|
18f952f4fc | ||
|
|
389f4efa4d | ||
|
|
d1e6b052d9 | ||
|
|
7fd360495b | ||
|
|
187e26810d | ||
|
|
8a1edfe7cf | ||
|
|
81d405d7be | ||
|
|
c4148f0494 | ||
|
|
a8f996bec7 | ||
|
|
faa81ea1a5 | ||
|
|
ec360d3a28 | ||
|
|
749074604f | ||
|
|
c60a6a78c8 | ||
|
|
f15b803851 | ||
|
|
c949359d6f | ||
|
|
73d4707420 | ||
|
|
4f731d9de8 | ||
|
|
2b400beb31 | ||
|
|
a3616b56f5 | ||
|
|
65a162bdd2 | ||
|
|
0e7c2f00d1 | ||
|
|
2b59d02ed9 | ||
|
|
45c6d3996e | ||
|
|
bb7545824a | ||
|
|
feb2154257 | ||
|
|
8551fcf08f | ||
|
|
61b094ee7d | ||
|
|
42714066bf | ||
|
|
94350683bd | ||
|
|
3d7950bb51 | ||
|
|
ec4b777429 | ||
|
|
61b61bce1c | ||
|
|
dcfb5ab054 | ||
|
|
6346f82a0a | ||
|
|
31f1d5b530 | ||
|
|
fc71c0f5c8 | ||
|
|
7ab368a424 | ||
|
|
1cee36ec9f | ||
|
|
74635281de | ||
|
|
0f43ad59a0 | ||
|
|
b97ea17950 | ||
|
|
3f48ca401d | ||
|
|
3ecad0161b | ||
|
|
9ff331e2db | ||
|
|
27e3b6f0d0 | ||
|
|
6a739b7a25 | ||
|
|
56313e5db8 | ||
|
|
d8e4a4b891 | ||
|
|
dee1932a64 | ||
|
|
661b9b2cc1 | ||
|
|
f24fb61e20 | ||
|
|
0993b43c79 | ||
|
|
ec403d7b1f | ||
|
|
f4fa1582a7 | ||
|
|
e5a84b1505 | ||
|
|
ce871fe2a2 | ||
|
|
5a713fe92f | ||
|
|
819df9cf3d | ||
|
|
a67e0b052f | ||
|
|
c088259e46 | ||
|
|
82f8636af5 | ||
|
|
f0ad4dad9f | ||
|
|
acf19823b0 | ||
|
|
c02fba8d82 | ||
|
|
a4f36f8620 | ||
|
|
987c27a221 | ||
|
|
248f887fb3 | ||
|
|
8fd07070b8 | ||
|
|
45c918f4ee | ||
|
|
4f267a0275 | ||
|
|
ad1caaf5dd | ||
|
|
1e0528fca0 | ||
|
|
b30df582f2 | ||
|
|
f0b42cdc09 | ||
|
|
308660287e | ||
|
|
c80a8e9601 | ||
|
|
059d17f8d6 | ||
|
|
de027eccad | ||
|
|
643494a54b | ||
|
|
71a010d5d7 | ||
|
|
b419586504 | ||
|
|
78a519c649 | ||
|
|
23ee3d18a6 | ||
|
|
a6b2f1b385 | ||
|
|
30a71ac8b7 | ||
|
|
ae9e5a0566 | ||
|
|
f24c8052a9 | ||
|
|
b0fa14fcfe | ||
|
|
338c2a1191 | ||
|
|
405a150a2b | ||
|
|
3c402f8787 | ||
|
|
7d10efc286 | ||
|
|
cf5205e976 | ||
|
|
eab072831d | ||
|
|
c4e928ff09 | ||
|
|
97024d828e | ||
|
|
c6e500ba71 | ||
|
|
eb39d97cc5 | ||
|
|
071eaddfd1 | ||
|
|
0eec9b185f | ||
|
|
5edb62e76b | ||
|
|
9bc5a050eb | ||
|
|
4a80f224d8 | ||
|
|
0608d3cf19 | ||
|
|
8fbe6bb17d | ||
|
|
60929342fb | ||
|
|
e0d43020dc | ||
|
|
2de0276195 | ||
|
|
1011b062ae | ||
|
|
c8b530cc1a | ||
|
|
6e72c343ab | ||
|
|
1c37186461 | ||
|
|
34a59db4cf | ||
|
|
12f61d03be | ||
|
|
aca9218f9d | ||
|
|
b727a704a6 | ||
|
|
1e03eed6c0 | ||
|
|
e9fcdcb049 | ||
|
|
5b7f1ef18a | ||
|
|
715128a653 | ||
|
|
83fbdbb135 | ||
|
|
2e963bdc8e | ||
|
|
8d6e93ea4f | ||
|
|
112a8cdf2f | ||
|
|
27476d8b23 | ||
|
|
2ad2d69b2b | ||
|
|
a3d6944fc1 | ||
|
|
552169ee77 | ||
|
|
4f03ab18e5 | ||
|
|
184d5d97e7 | ||
|
|
ba667a0768 | ||
|
|
15b3424d7f | ||
|
|
98398a048b | ||
|
|
3cb066f5f5 | ||
|
|
053b38bee3 | ||
|
|
1f9356cc21 | ||
|
|
f07e7fd31c | ||
|
|
7794876d7c | ||
|
|
e77b4d7a82 | ||
|
|
af0d2d3cdc | ||
|
|
7e132be686 | ||
|
|
aba1972d0d | ||
|
|
0268bb6930 | ||
|
|
ecd6e6a066 | ||
|
|
6411c6169b | ||
|
|
a78467065a | ||
|
|
c05c74f009 | ||
|
|
ace29ca4a4 | ||
|
|
4f90d147a4 | ||
|
|
9348f211f0 | ||
|
|
729d9e4a39 | ||
|
|
3274088b54 | ||
|
|
49c841ca07 | ||
|
|
91f319df65 | ||
|
|
dbf4b0926e | ||
|
|
994f31b7e5 | ||
|
|
6213067f35 | ||
|
|
76fb45c97e | ||
|
|
2bf5f276f5 | ||
|
|
eaadd6f7af | ||
|
|
86c6acb7b8 | ||
|
|
de32d899bc | ||
|
|
d4356ba6e6 | ||
|
|
275aee4de2 | ||
|
|
57075c581d | ||
|
|
d8442e435d | ||
|
|
e954a860bf | ||
|
|
5598fe0f53 | ||
|
|
e77508edcc | ||
|
|
c517c0521c | ||
|
|
e22856ff74 | ||
|
|
a30687e4ea | ||
|
|
64ba346566 | ||
|
|
3745b297db | ||
|
|
401418c049 | ||
|
|
7adb40489d | ||
|
|
482314b9f4 | ||
|
|
138e40315d | ||
|
|
7d6afd47b1 | ||
|
|
ed1f650fc6 | ||
|
|
17e4e06fcc | ||
|
|
654b36ab08 | ||
|
|
9abbfc5b1e | ||
|
|
c9d906316f | ||
|
|
8d476e0729 | ||
|
|
7a320c9574 | ||
|
|
3f1392ce62 | ||
|
|
79e54ea230 | ||
|
|
e2473207ba | ||
|
|
fb961dd47b | ||
|
|
9a62bcd8fb |
@@ -1,4 +1,5 @@
|
||||
./.github
|
||||
./.stryker-tmp
|
||||
./build
|
||||
./coverage
|
||||
./node_modules
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"ignorePatterns": ["src/service*.ts"],
|
||||
"rules": {
|
||||
"complexity": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "off"
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "off",
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off"
|
||||
}
|
||||
}
|
||||
|
||||
2
.github/workflows/publish-release.yml
vendored
2
.github/workflows/publish-release.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
with:
|
||||
node-version: 16.13
|
||||
- name: Generate release assets
|
||||
run: npm ci && npm run build ${GITHUB_REF#refs/tags/v}
|
||||
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
|
||||
- name: Publish release with assets
|
||||
uses: docker://antonyurchenko/git-release:latest
|
||||
env:
|
||||
|
||||
79
CHANGELOG.md
79
CHANGELOG.md
@@ -4,6 +4,85 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [3.6.0] - 2022-03-17
|
||||
### Added
|
||||
* [#558](https://github.com/shlinkio/shlink-web-client/pull/558) Added dark text for tags where the generated background is too light, improving its legibility.
|
||||
* [#570](https://github.com/shlinkio/shlink-web-client/pull/570) Added new section to load non-orphan visits all together when consuming Shlink 3.0.0.
|
||||
* [#556](https://github.com/shlinkio/shlink-web-client/pull/556) Added support to filter short URLs list by "all" tags when consuming Shlink 3.0.0.
|
||||
* [#549](https://github.com/shlinkio/shlink-web-client/pull/549) Allowed to export the list of short URLs as CSV.
|
||||
|
||||
### Changed
|
||||
* [#543](https://github.com/shlinkio/shlink-web-client/pull/543) Redesigned settings section.
|
||||
* [#567](https://github.com/shlinkio/shlink-web-client/pull/567) Improved Shlink 3.0.0 compatibility by checking the `INVALID_SHORT_URL_DELETION` error code when deleting short URLs.
|
||||
* [#448](https://github.com/shlinkio/shlink-web-client/pull/448) Updated to bootstrap v5.
|
||||
* [#524](https://github.com/shlinkio/shlink-web-client/pull/524) Updated to react-router v6.
|
||||
* [#576](https://github.com/shlinkio/shlink-web-client/pull/576) Updated to fontawesome v6.
|
||||
* [#579](https://github.com/shlinkio/shlink-web-client/pull/579) Replaced react-color with react-colorful.
|
||||
* [#564](https://github.com/shlinkio/shlink-web-client/pull/564) Updated most of the dependencies.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#589](https://github.com/shlinkio/shlink-web-client/pull/589) Fixed alignment of shlink versions footer, by basing the logic on the presence of the sidebar instead of selected server.
|
||||
|
||||
|
||||
## [3.5.1] - 2022-01-08
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#555](https://github.com/shlinkio/shlink-web-client/issues/555) Fixed vertical alignment in welcome screen logo.
|
||||
* [#554](https://github.com/shlinkio/shlink-web-client/issues/554) Fixed behavior in overview page, where items in the list of short URLs were stripped out when creating new ones, even if the amount of short URLs was still not yet big enough.
|
||||
* [#557](https://github.com/shlinkio/shlink-web-client/issues/557) Fixed new tags added to new short URLs, not appearing on tags autosuggest.
|
||||
|
||||
|
||||
## [3.5.0] - 2022-01-01
|
||||
### Added
|
||||
* [#407](https://github.com/shlinkio/shlink-web-client/pull/407) Improved how visits (short URLs, tags and orphan) are loaded, to avoid ending up in a page with "There are no visits matching current filter".
|
||||
|
||||
Now, the app will try to load visits for the configured default interval, and in parallel, it will load the latest visit.
|
||||
|
||||
If the resulting list for that interval is empty, it will try to infer the closest interval with visits, based on the latest visit's date, and reload visits for that interval.
|
||||
|
||||
* [#547](https://github.com/shlinkio/shlink-web-client/pull/547) Improved domains page, to tell which of the domains are not properly configured.
|
||||
|
||||
Now, when this section is loaded, it tries to call the `GET /rest/health` endpoint for each one of the domains, and displays a warning icon on each one that failed.
|
||||
|
||||
The warning includes a link to the documentation, explaining what are the steps to get it fixed.
|
||||
|
||||
* [#506](https://github.com/shlinkio/shlink-web-client/pull/506) Improved how servers are handled, displaying a warning when creating or importing servers that already exist.
|
||||
* [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer.
|
||||
* [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page.
|
||||
* [#537](https://github.com/shlinkio/shlink-web-client/pull/537) Allowed to customize the ordering for every list in the app that supports it, being currently tags and short URLs.
|
||||
* [#542](https://github.com/shlinkio/shlink-web-client/pull/542) Added ordering for short URLs to the query, so that it is consistent with the rest of the filtering params.
|
||||
|
||||
### Changed
|
||||
* [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios.
|
||||
* [#538](https://github.com/shlinkio/shlink-web-client/pull/538) Switched to the `<field>-<dir>` notation in `orderBy` param for short URLs list, in preparation for Shlink v3.0.0
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## [3.4.2] - 2021-12-07
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
@@ -2,8 +2,7 @@ FROM node:16.13-alpine as node
|
||||
COPY . /shlink-web-client
|
||||
ARG VERSION="latest"
|
||||
ENV VERSION ${VERSION}
|
||||
RUN cd /shlink-web-client && \
|
||||
npm install && npm run build -- ${VERSION} --no-dist
|
||||
RUN cd /shlink-web-client && npm ci && npm run build
|
||||
|
||||
FROM nginx:1.21-alpine
|
||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
||||
[](https://twitter.com/shlinkio)
|
||||
[](https://slnk.to/donate)
|
||||
|
||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||
|
||||
15
babel.config.js
Normal file
15
babel.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
[
|
||||
'react-app',
|
||||
{
|
||||
runtime: 'automatic',
|
||||
typescript: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
'@babel/plugin-proposal-optional-chaining',
|
||||
'@babel/plugin-proposal-nullish-coalescing-operator',
|
||||
],
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
|
||||
// This is a custom Jest transformer turning style imports into empty objects.
|
||||
// http://facebook.github.io/jest/docs/en/webpack.html
|
||||
|
||||
module.exports = {
|
||||
process() {
|
||||
return 'module.exports = {};';
|
||||
},
|
||||
getCacheKey() {
|
||||
// The output is always the same.
|
||||
return 'cssTransform';
|
||||
},
|
||||
};
|
||||
@@ -15,37 +15,23 @@ module.exports = {
|
||||
lines: 85,
|
||||
},
|
||||
},
|
||||
resolver: 'jest-pnp-resolver',
|
||||
setupFiles: [
|
||||
'react-app-polyfill/jsdom',
|
||||
'<rootDir>/config/setupEnzyme.js',
|
||||
],
|
||||
testMatch: [ '<rootDir>/test/**/*.test.{js,jsx,ts,tsx}' ],
|
||||
setupFiles: [ '<rootDir>/config/setupEnzyme.js' ],
|
||||
testMatch: [ '<rootDir>/test/**/*.test.{ts,tsx}' ],
|
||||
testEnvironment: 'jsdom',
|
||||
testURL: 'http://localhost',
|
||||
transform: {
|
||||
'^.+\\.(ts|tsx|js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
|
||||
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
|
||||
'^(?!.*\\.(ts|tsx|js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
|
||||
'^.+\\.(ts|tsx|js)$': '<rootDir>/node_modules/babel-jest',
|
||||
'^(?!.*\\.(ts|tsx|js|json)$)': '<rootDir>/config/jest/fileTransform.js',
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'<rootDir>/.stryker-tmp',
|
||||
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
|
||||
'^.+\\.module\\.(css|sass|scss)$',
|
||||
'^.+\\.module\\.scss$',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^react-native$': 'react-native-web',
|
||||
'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy',
|
||||
'^.+\\.module\\.scss$': 'identity-obj-proxy',
|
||||
// Reactstrap module resolution does not work in jest for some reason. Manually mapping it solves the problem
|
||||
'reactstrap': '<rootDir>/node_modules/reactstrap/dist/reactstrap.umd.js',
|
||||
},
|
||||
moduleFileExtensions: [
|
||||
'web.js',
|
||||
'js',
|
||||
'web.ts',
|
||||
'ts',
|
||||
'web.tsx',
|
||||
'tsx',
|
||||
'json',
|
||||
'web.jsx',
|
||||
'jsx',
|
||||
'node',
|
||||
],
|
||||
moduleFileExtensions: [ 'js', 'ts', 'tsx', 'json' ],
|
||||
};
|
||||
|
||||
16631
package-lock.json
generated
16631
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
173
package.json
173
package.json
@@ -14,7 +14,8 @@
|
||||
"lint:js:fix": "npm run lint:js -- --fix",
|
||||
"start": "node scripts/start.js",
|
||||
"serve:build": "serve ./build",
|
||||
"build": "node scripts/build.js",
|
||||
"build": "node scripts/build.js && node scripts/replace-version.js",
|
||||
"build:dist": "npm run build && node scripts/create-dist-file.js",
|
||||
"test": "node scripts/test.js --env=jsdom --colors --verbose",
|
||||
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
|
||||
"test:ci": "npm run test:coverage -- --coverageReporters=clover",
|
||||
@@ -22,84 +23,81 @@
|
||||
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.15.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.34",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.15.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
||||
"@fortawesome/react-fontawesome": "^0.1.14",
|
||||
"axios": "^0.21.1",
|
||||
"bootstrap": "^4.6.0",
|
||||
"@fortawesome/fontawesome-free": "^6.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.3.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.0.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.0.0",
|
||||
"@fortawesome/react-fontawesome": "^0.1.17",
|
||||
"axios": "^0.26.0",
|
||||
"bootstrap": "^5.1.3",
|
||||
"bottlejs": "^2.0.0",
|
||||
"bowser": "^2.11.0",
|
||||
"chart.js": "^3.5.1",
|
||||
"classnames": "^2.2.6",
|
||||
"compare-versions": "^3.6.0",
|
||||
"chart.js": "^3.7.1",
|
||||
"classnames": "^2.3.1",
|
||||
"compare-versions": "^4.1.3",
|
||||
"csvjson": "^5.1.0",
|
||||
"date-fns": "^2.22.1",
|
||||
"event-source-polyfill": "^1.0.22",
|
||||
"date-fns": "^2.28.0",
|
||||
"event-source-polyfill": "^1.0.25",
|
||||
"leaflet": "^1.7.1",
|
||||
"promise": "^8.1.0",
|
||||
"qs": "^6.9.6",
|
||||
"ramda": "^0.27.1",
|
||||
"react": "^17.0.1",
|
||||
"react-chartjs-2": "^3.0.4",
|
||||
"react-color": "^2.19.3",
|
||||
"react-copy-to-clipboard": "^5.0.2",
|
||||
"react-datepicker": "^3.6.0",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-external-link": "^1.2.0",
|
||||
"react-leaflet": "^3.1.0",
|
||||
"react-redux": "^7.2.2",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-swipeable": "^6.0.1",
|
||||
"react-tag-autocomplete": "^6.1.0",
|
||||
"reactstrap": "^8.9.0",
|
||||
"redux": "^4.0.5",
|
||||
"redux-localstorage-simple": "^2.4.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"ramda": "^0.27.2",
|
||||
"react": "^17.0.2",
|
||||
"react-chartjs-2": "^3.3.0",
|
||||
"react-colorful": "^5.5.1",
|
||||
"react-copy-to-clipboard": "^5.0.4",
|
||||
"react-datepicker": "^4.7.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-external-link": "^1.2.2",
|
||||
"react-leaflet": "^3.2.5",
|
||||
"react-redux": "^7.2.6",
|
||||
"react-router-dom": "^6.2.2",
|
||||
"react-swipeable": "^6.2.0",
|
||||
"react-tag-autocomplete": "^6.3.0",
|
||||
"reactstrap": "^9.0.1",
|
||||
"redux": "^4.1.2",
|
||||
"redux-localstorage-simple": "^2.4.1",
|
||||
"redux-thunk": "^2.4.1",
|
||||
"uuid": "^8.3.2",
|
||||
"workbox-core": "^6.1.5",
|
||||
"workbox-expiration": "^6.1.5",
|
||||
"workbox-precaching": "^6.1.5",
|
||||
"workbox-routing": "^6.1.5",
|
||||
"workbox-strategies": "^6.1.5"
|
||||
"workbox-core": "^6.5.1",
|
||||
"workbox-expiration": "^6.5.1",
|
||||
"workbox-precaching": "^6.5.1",
|
||||
"workbox-routing": "^6.5.1",
|
||||
"workbox-strategies": "^6.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.13.8",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
|
||||
"@babel/core": "^7.17.5",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.16.7",
|
||||
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
|
||||
"@stryker-mutator/core": "^5.4.1",
|
||||
"@stryker-mutator/jest-runner": "^5.4.1",
|
||||
"@stryker-mutator/typescript-checker": "^5.4.1",
|
||||
"@stryker-mutator/core": "^5.6.1",
|
||||
"@stryker-mutator/jest-runner": "^5.6.1",
|
||||
"@stryker-mutator/typescript-checker": "^5.6.1",
|
||||
"@svgr/webpack": "^5.5.0",
|
||||
"@types/classnames": "^2.2.11",
|
||||
"@types/enzyme": "^3.10.10",
|
||||
"@types/jest": "^27.0.2",
|
||||
"@types/leaflet": "^1.5.23",
|
||||
"@types/qs": "^6.9.5",
|
||||
"@types/ramda": "^0.27.38",
|
||||
"@types/react": "^17.0.2",
|
||||
"@types/react-color": "^3.0.4",
|
||||
"@types/react-copy-to-clipboard": "^5.0.0",
|
||||
"@types/react-datepicker": "^3.1.5",
|
||||
"@types/react-dom": "^17.0.1",
|
||||
"@types/react-leaflet": "^2.5.2",
|
||||
"@types/react-redux": "^7.1.16",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"@types/react-tag-autocomplete": "^6.1.0",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
|
||||
"adm-zip": "^0.4.16",
|
||||
"autoprefixer": "^10.0.2",
|
||||
"babel-core": "7.0.0-bridge.0",
|
||||
"babel-jest": "^27.3.1",
|
||||
"babel-loader": "^8.2.1",
|
||||
"babel-plugin-named-asset-import": "^0.3.7",
|
||||
"babel-preset-react-app": "^10.0.0",
|
||||
"@types/classnames": "^2.3.1",
|
||||
"@types/enzyme": "^3.10.11",
|
||||
"@types/jest": "^27.4.1",
|
||||
"@types/leaflet": "^1.7.9",
|
||||
"@types/qs": "^6.9.7",
|
||||
"@types/ramda": "0.27.38",
|
||||
"@types/react": "^17.0.39",
|
||||
"@types/react-color": "^3.0.6",
|
||||
"@types/react-copy-to-clipboard": "^5.0.2",
|
||||
"@types/react-datepicker": "^4.3.4",
|
||||
"@types/react-dom": "^17.0.13",
|
||||
"@types/react-leaflet": "^2.8.2",
|
||||
"@types/react-redux": "^7.1.23",
|
||||
"@types/react-tag-autocomplete": "^6.1.1",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@wojtekmaj/enzyme-adapter-react-17": "0.6.5",
|
||||
"adm-zip": "^0.5.9",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"babel-jest": "^27.5.1",
|
||||
"babel-loader": "^8.2.3",
|
||||
"babel-plugin-named-asset-import": "^0.3.8",
|
||||
"babel-preset-react-app": "10.0.0",
|
||||
"babel-runtime": "^6.26.0",
|
||||
"bfj": "^7.0.2",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.3.0",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.4.0",
|
||||
"chalk": "^4.1.2",
|
||||
"css-loader": "^5.0.1",
|
||||
"dart-sass": "^1.25.0",
|
||||
@@ -113,26 +111,22 @@
|
||||
"fs-extra": "^9.0.1",
|
||||
"html-webpack-plugin": "^4.5.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^27.3.1",
|
||||
"jest-pnp-resolver": "^1.2.2",
|
||||
"jest-resolve": "^27.3.1",
|
||||
"jest": "^27.5.1",
|
||||
"mini-css-extract-plugin": "^1.3.1",
|
||||
"object-assign": "^4.1.1",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.4",
|
||||
"pnp-webpack-plugin": "^1.6.4",
|
||||
"postcss": "^8.1.7",
|
||||
"pnp-webpack-plugin": "^1.7.0",
|
||||
"postcss": "^8.4.8",
|
||||
"postcss-flexbugs-fixes": "^4.2.1",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"postcss-safe-parser": "^5.0.2",
|
||||
"raf": "^3.4.1",
|
||||
"react-app-polyfill": "^2.0.0",
|
||||
"react-dev-utils": "^11.0.0",
|
||||
"resolve": "^1.19.0",
|
||||
"sass": "^1.29.0",
|
||||
"resolve": "^1.22.0",
|
||||
"sass": "^1.49.9",
|
||||
"sass-loader": "^10.1.0",
|
||||
"serve": "^12.0.0",
|
||||
"stryker-cli": "^1.0.0",
|
||||
"stryker-cli": "^1.0.2",
|
||||
"style-loader": "^2.0.0",
|
||||
"stylelint": "^13.7.2",
|
||||
"stylelint-config-adidas": "^1.3.0",
|
||||
@@ -141,29 +135,14 @@
|
||||
"stylelint-scss": "^3.18.0",
|
||||
"sw-precache-webpack-plugin": "^1.0.0",
|
||||
"terser-webpack-plugin": "^4.2.3",
|
||||
"ts-jest": "^27.0.7",
|
||||
"ts-mockery": "^1.2.0",
|
||||
"typescript": "^4.4.4",
|
||||
"typescript": "^4.6.2",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^4.44.2",
|
||||
"webpack-dev-server": "^3.11.0",
|
||||
"webpack": "^4.46.0",
|
||||
"webpack-dev-server": "^3.11.3",
|
||||
"webpack-manifest-plugin": "^2.2.0",
|
||||
"whatwg-fetch": "^3.5.0",
|
||||
"workbox-webpack-plugin": "^6.1.5"
|
||||
},
|
||||
"babel": {
|
||||
"presets": [
|
||||
[
|
||||
"react-app",
|
||||
{
|
||||
"runtime": "automatic"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator"
|
||||
]
|
||||
"whatwg-fetch": "^3.6.2",
|
||||
"workbox-webpack-plugin": "^6.5.1"
|
||||
},
|
||||
"browserslist": [
|
||||
">0.2%",
|
||||
|
||||
@@ -18,7 +18,6 @@ const chalk = require('chalk');
|
||||
const fs = require('fs-extra');
|
||||
const webpack = require('webpack');
|
||||
const bfj = require('bfj');
|
||||
const AdmZip = require('adm-zip');
|
||||
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
|
||||
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
|
||||
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
|
||||
@@ -44,8 +43,6 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
|
||||
const argvSliceStart = 2;
|
||||
const argv = process.argv.slice(argvSliceStart);
|
||||
const writeStatsJson = argv.includes('--stats');
|
||||
const withoutDist = argv.includes('--no-dist');
|
||||
const { version, hasVersion } = getVersionFromArgs(argv);
|
||||
|
||||
// Generate configuration
|
||||
const config = configFactory('production');
|
||||
@@ -84,7 +81,6 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green('Compiled successfully.\n'));
|
||||
hasVersion && replaceVersionPlaceholder(version);
|
||||
}
|
||||
|
||||
console.log('File sizes after gzip:\n');
|
||||
@@ -103,7 +99,6 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
process.exit(1);
|
||||
},
|
||||
)
|
||||
.then(() => hasVersion && !withoutDist && zipDist(version))
|
||||
.catch((err) => {
|
||||
if (err && err.message) {
|
||||
console.log(err.message);
|
||||
@@ -185,43 +180,3 @@ function copyPublicFolder() {
|
||||
filter: (file) => file !== paths.appHtml,
|
||||
});
|
||||
}
|
||||
|
||||
function zipDist(version) {
|
||||
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
|
||||
|
||||
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
|
||||
const zip = new AdmZip();
|
||||
|
||||
try {
|
||||
if (fs.existsSync(versionFileName)) {
|
||||
fs.unlink(versionFileName);
|
||||
}
|
||||
|
||||
zip.addLocalFolder('./build', `shlink-web-client_${version}_dist`);
|
||||
zip.writeZip(versionFileName);
|
||||
console.log(chalk.green('Dist file properly generated'));
|
||||
} catch (e) {
|
||||
console.log(chalk.red('An error occurred while generating dist file'));
|
||||
console.log(e);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
function getVersionFromArgs(argv) {
|
||||
const [ version ] = argv;
|
||||
|
||||
return { version, hasVersion: !!version };
|
||||
}
|
||||
|
||||
function replaceVersionPlaceholder(version) {
|
||||
const staticJsFilesPath = './build/static/js';
|
||||
const versionPlaceholder = '%_VERSION_%';
|
||||
|
||||
const isMainFile = (file) => file.startsWith('main.') && file.endsWith('.js');
|
||||
const [ mainJsFile ] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
|
||||
const filePath = `${staticJsFilesPath}/${mainJsFile}`;
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||
const replaced = fileContent.replace(versionPlaceholder, version);
|
||||
|
||||
fs.writeFileSync(filePath, replaced, 'utf-8');
|
||||
}
|
||||
|
||||
36
scripts/create-dist-file.js
Normal file
36
scripts/create-dist-file.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/* eslint-disable no-console, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
||||
|
||||
// Do this as the first thing so that any code reading it knows the right env.
|
||||
process.env.BABEL_ENV = 'production';
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const chalk = require('chalk');
|
||||
const AdmZip = require('adm-zip');
|
||||
const fs = require('fs-extra');
|
||||
|
||||
function zipDist(version) {
|
||||
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
|
||||
|
||||
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
|
||||
const zip = new AdmZip();
|
||||
|
||||
try {
|
||||
if (fs.existsSync(versionFileName)) {
|
||||
fs.unlink(versionFileName);
|
||||
}
|
||||
|
||||
zip.addLocalFolder('./build', `shlink-web-client_${version}_dist`);
|
||||
zip.writeZip(versionFileName);
|
||||
console.log(chalk.green('Dist file properly generated'));
|
||||
} catch (e) {
|
||||
console.log(chalk.red('An error occurred while generating dist file'));
|
||||
console.log(e);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
const version = process.env.VERSION;
|
||||
|
||||
if (version) {
|
||||
zipDist(version);
|
||||
}
|
||||
20
scripts/replace-version.js
Normal file
20
scripts/replace-version.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const fs = require('fs-extra');
|
||||
|
||||
function replaceVersionPlaceholder(version) {
|
||||
const staticJsFilesPath = './build/static/js';
|
||||
const versionPlaceholder = '%_VERSION_%';
|
||||
|
||||
const isMainFile = (file) => file.startsWith('main.') && file.endsWith('.js');
|
||||
const [ mainJsFile ] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
|
||||
const filePath = `${staticJsFilesPath}/${mainJsFile}`;
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||
const replaced = fileContent.replace(versionPlaceholder, version);
|
||||
|
||||
fs.writeFileSync(filePath, replaced, 'utf-8');
|
||||
}
|
||||
|
||||
const version = process.env.VERSION;
|
||||
|
||||
if (version) {
|
||||
replaceVersionPlaceholder(version);
|
||||
}
|
||||
2
shlink-web-client.d.ts
vendored
2
shlink-web-client.d.ts
vendored
@@ -10,7 +10,7 @@ declare module 'event-source-polyfill' {
|
||||
declare module 'csvjson' {
|
||||
export declare class CsvJson {
|
||||
public toObject<T>(content: string): T[];
|
||||
public toCSV<T>(data: T[], options: { headers: 'full' | 'none' | 'relative' | 'key' }): string;
|
||||
public toCSV<T>(data: T[], options: { headers: 'full' | 'none' | 'relative' | 'key'; wrap?: true }): string;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,31 +11,34 @@ import {
|
||||
ShlinkVisits,
|
||||
ShlinkVisitsParams,
|
||||
ShlinkShortUrlData,
|
||||
ShlinkDomain,
|
||||
ShlinkDomainsResponse,
|
||||
ShlinkVisitsOverview,
|
||||
ShlinkEditDomainRedirects,
|
||||
ShlinkDomainRedirects,
|
||||
ShlinkShortUrlsListParams,
|
||||
ShlinkShortUrlsListNormalizedParams,
|
||||
} from '../types';
|
||||
import { stringifyQuery } from '../../utils/helpers/query';
|
||||
import { orderToString } from '../../utils/helpers/ordering';
|
||||
|
||||
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
||||
const buildShlinkBaseUrl = (url: string) => url ? `${url}/rest/v2` : '';
|
||||
const rejectNilProps = reject(isNil);
|
||||
const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => {
|
||||
const { orderBy = {}, ...rest } = params;
|
||||
|
||||
return { ...rest, orderBy: orderToString(orderBy) };
|
||||
};
|
||||
|
||||
export default class ShlinkApiClient {
|
||||
private apiVersion: number;
|
||||
|
||||
public constructor(
|
||||
private readonly axios: AxiosInstance,
|
||||
private readonly baseUrl: string,
|
||||
private readonly apiKey: string,
|
||||
) {
|
||||
this.apiVersion = 2;
|
||||
}
|
||||
|
||||
public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
|
||||
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params)
|
||||
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeOrderByInParams(params))
|
||||
.then(({ data }) => data.shortUrls);
|
||||
|
||||
public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => {
|
||||
@@ -57,6 +60,10 @@ export default class ShlinkApiClient {
|
||||
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query)
|
||||
.then(({ data }) => data.visits);
|
||||
|
||||
public readonly getNonOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
||||
this.performRequest<{ visits: ShlinkVisits }>('/visits/non-orphan', 'GET', query)
|
||||
.then(({ data }) => data.visits);
|
||||
|
||||
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
|
||||
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
|
||||
.then(({ data }) => data.visits);
|
||||
@@ -69,7 +76,10 @@ export default class ShlinkApiClient {
|
||||
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
|
||||
.then(() => {});
|
||||
|
||||
/* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead */
|
||||
// eslint-disable-next-line valid-jsdoc
|
||||
/**
|
||||
* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead
|
||||
*/
|
||||
public readonly updateShortUrlTags = async (
|
||||
shortCode: string,
|
||||
domain: OptionalString,
|
||||
@@ -107,43 +117,21 @@ export default class ShlinkApiClient {
|
||||
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
|
||||
.then((resp) => resp.data);
|
||||
|
||||
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
|
||||
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data);
|
||||
public readonly listDomains = async (): Promise<ShlinkDomainsResponse> =>
|
||||
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains);
|
||||
|
||||
public readonly editDomainRedirects = async (
|
||||
domainRedirects: ShlinkEditDomainRedirects,
|
||||
): Promise<ShlinkDomainRedirects> =>
|
||||
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data);
|
||||
|
||||
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
|
||||
try {
|
||||
return await this.axios({
|
||||
method,
|
||||
url: `${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}`,
|
||||
headers: { 'X-Api-Key': this.apiKey },
|
||||
params: rejectNilProps(query),
|
||||
data: body,
|
||||
paramsSerializer: stringifyQuery,
|
||||
});
|
||||
} catch (e: any) {
|
||||
const { response } = e;
|
||||
|
||||
// Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error
|
||||
// when performed from the browser (due to the preflight request not returning a 2xx status.
|
||||
// See https://github.com/shlinkio/shlink/issues/614), which will make the "response" prop not to be set here.
|
||||
// The bug will be fixed on upcoming Shlink patches, but for other versions, we can consider this situation as
|
||||
// if a request has been performed to a not supported API version.
|
||||
const apiVersionIsNotSupported = !response;
|
||||
|
||||
// When the request is not invalid or we have already tried both API versions, throw the error and let the
|
||||
// caller handle it
|
||||
if (!apiVersionIsNotSupported || this.apiVersion === 2) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
this.apiVersion = this.apiVersion - 1;
|
||||
|
||||
return await this.performRequest(url, method, query, body);
|
||||
}
|
||||
};
|
||||
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> =>
|
||||
this.axios({
|
||||
method,
|
||||
url: `${buildShlinkBaseUrl(this.baseUrl)}${url}`,
|
||||
headers: { 'X-Api-Key': this.apiKey },
|
||||
params: rejectNilProps(query),
|
||||
data: body,
|
||||
paramsSerializer: stringifyQuery,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Visit } from '../../visits/types';
|
||||
import { OptionalString } from '../../utils/utils';
|
||||
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
|
||||
import { OrderBy } from '../../short-urls/reducers/shortUrlsListParams';
|
||||
import { ShortUrl, ShortUrlMeta, ShortUrlsOrder } from '../../short-urls/data';
|
||||
|
||||
export interface ShlinkShortUrlsResponse {
|
||||
data: ShortUrl[];
|
||||
@@ -84,8 +83,11 @@ export interface ShlinkDomain {
|
||||
|
||||
export interface ShlinkDomainsResponse {
|
||||
data: ShlinkDomain[];
|
||||
defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10
|
||||
}
|
||||
|
||||
export type TagsFilteringMode = 'all' | 'any';
|
||||
|
||||
export interface ShlinkShortUrlsListParams {
|
||||
page?: string;
|
||||
itemsPerPage?: number;
|
||||
@@ -93,7 +95,12 @@ export interface ShlinkShortUrlsListParams {
|
||||
searchTerm?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
orderBy?: OrderBy;
|
||||
orderBy?: ShortUrlsOrder;
|
||||
tagsMode?: TagsFilteringMode;
|
||||
}
|
||||
|
||||
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> {
|
||||
orderBy?: string;
|
||||
}
|
||||
|
||||
export interface ProblemDetailsError {
|
||||
@@ -110,6 +117,6 @@ export interface InvalidArgumentError extends ProblemDetailsError {
|
||||
}
|
||||
|
||||
export interface InvalidShortUrlDeletion extends ProblemDetailsError {
|
||||
type: 'INVALID_SHORTCODE_DELETION';
|
||||
type: 'INVALID_SHORTCODE_DELETION' | 'INVALID_SHORT_URL_DELETION';
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ export const isInvalidArgumentError = (error?: ProblemDetailsError): error is In
|
||||
error?.type === 'INVALID_ARGUMENT';
|
||||
|
||||
export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion =>
|
||||
error?.type === 'INVALID_SHORTCODE_DELETION';
|
||||
error?.type === 'INVALID_SHORTCODE_DELETION' || error?.type === 'INVALID_SHORT_URL_DELETION';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, FC } from 'react';
|
||||
import { Route, RouteChildrenProps, Switch } from 'react-router-dom';
|
||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import NotFound from '../common/NotFound';
|
||||
import { ServersMap } from '../servers/data';
|
||||
@@ -9,7 +9,7 @@ import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
||||
import { forceUpdate } from '../utils/helpers/sw';
|
||||
import './App.scss';
|
||||
|
||||
interface AppProps extends RouteChildrenProps {
|
||||
interface AppProps {
|
||||
fetchServers: () => void;
|
||||
servers: ServersMap;
|
||||
settings: Settings;
|
||||
@@ -26,7 +26,8 @@ const App = (
|
||||
Settings: FC,
|
||||
ManageServers: FC,
|
||||
ShlinkVersionsContainer: FC,
|
||||
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate, location }: AppProps) => {
|
||||
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
|
||||
const location = useLocation();
|
||||
const isHome = location.pathname === '/';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,15 +45,15 @@ const App = (
|
||||
|
||||
<div className="app">
|
||||
<div className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
|
||||
<Switch>
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route exact path="/settings" component={Settings} />
|
||||
<Route exact path="/manage-servers" component={ManageServers} />
|
||||
<Route exact path="/server/create" component={CreateServer} />
|
||||
<Route exact path="/server/:serverId/edit" component={EditServer} />
|
||||
<Route path="/server/:serverId" component={MenuLayout} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
<Routes>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="/settings/*" element={<Settings />} />
|
||||
<Route path="/manage-servers" element={<ManageServers />} />
|
||||
<Route path="/server/create" element={<CreateServer />} />
|
||||
<Route path="/server/:serverId/edit" element={<EditServer />} />
|
||||
<Route path="/server/:serverId/*" element={<MenuLayout />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
<div className="shlink-footer">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import Bottle, { Decorator } from 'bottlejs';
|
||||
import Bottle from 'bottlejs';
|
||||
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
|
||||
import App from '../App';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory(
|
||||
'App',
|
||||
@@ -18,7 +18,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||
'ShlinkVersionsContainer',
|
||||
);
|
||||
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ]));
|
||||
bottle.decorator('App', withRouter);
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);
|
||||
|
||||
@@ -24,8 +24,8 @@ export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forc
|
||||
<h4 className="mb-4">This app has just been updated!</h4>
|
||||
<p className="mb-0">
|
||||
Restart it to enjoy the new features.
|
||||
<Button disabled={isUpdating} className="ml-2" color="secondary" size="sm" onClick={update}>
|
||||
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ml-1" /></>}
|
||||
<Button disabled={isUpdating} className="ms-2" color="secondary" size="sm" onClick={update}>
|
||||
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ms-1" /></>}
|
||||
{isUpdating && <>Restarting...</>}
|
||||
</Button>
|
||||
</p>
|
||||
|
||||
@@ -8,9 +8,8 @@ import {
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { FC } from 'react';
|
||||
import { NavLink, NavLinkProps } from 'react-router-dom';
|
||||
import { NavLink, NavLinkProps, useLocation } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import { Location } from 'history';
|
||||
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
||||
import { isServerWithId, SelectedServer } from '../servers/data';
|
||||
import { supportsDomainRedirects } from '../utils/helpers/features';
|
||||
@@ -28,8 +27,7 @@ interface AsideMenuItemProps extends NavLinkProps {
|
||||
|
||||
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
|
||||
<NavLink
|
||||
className={classNames('aside-menu__item', className)}
|
||||
activeClassName="aside-menu__item--selected"
|
||||
className={({ isActive }) => classNames('aside-menu__item', className, { 'aside-menu__item--selected': isActive })}
|
||||
to={to}
|
||||
{...rest}
|
||||
>
|
||||
@@ -42,11 +40,11 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||
) => {
|
||||
const hasId = isServerWithId(selectedServer);
|
||||
const serverId = hasId ? selectedServer.id : '';
|
||||
const { pathname } = useLocation();
|
||||
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
|
||||
const asideClass = classNames('aside-menu', {
|
||||
'aside-menu--hidden': !showOnMobile,
|
||||
});
|
||||
const shortUrlsIsActive = (_: null, location: Location) => location.pathname.match('/list-short-urls') !== null;
|
||||
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
|
||||
|
||||
return (
|
||||
@@ -56,7 +54,10 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
|
||||
<span className="aside-menu__item-text">Overview</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
||||
<AsideMenuItem
|
||||
to={buildPath('/list-short-urls/1')}
|
||||
className={classNames({ 'aside-menu__item--selected': pathname.match('/list-short-urls') !== null })}
|
||||
>
|
||||
<FontAwesomeIcon fixedWidth icon={listIcon} />
|
||||
<span className="aside-menu__item-text">List short URLs</span>
|
||||
</AsideMenuItem>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
@import '../utils/base';
|
||||
@import '../utils/mixins/vertical-align';
|
||||
|
||||
$mainCardWidth: 720px;
|
||||
$fiveColumnsSize: .4167; // 12 / 5 -> Can't use "/" operator in latest dart-sass
|
||||
|
||||
.home {
|
||||
position: relative;
|
||||
padding-top: 15px;
|
||||
@@ -12,19 +15,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
.home__logo-wrapper {
|
||||
padding: 1.5rem !important;
|
||||
height: 100% !important;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.home__logo {
|
||||
@include vertical-align();
|
||||
|
||||
width: calc(#{$mainCardWidth * $fiveColumnsSize} - 3rem);
|
||||
}
|
||||
|
||||
.home__main-card {
|
||||
margin: 0 auto;
|
||||
max-width: 720px;
|
||||
max-width: $mainCardWidth;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
@include vertical-align();
|
||||
}
|
||||
}
|
||||
|
||||
.home__title-wrapper {
|
||||
padding: 1.5rem !important;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.home__title {
|
||||
text-align: center;
|
||||
font-size: 1.75rem;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { isEmpty, values } from 'ramda';
|
||||
import { Link, RouteChildrenProps } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Card, Row } from 'reactstrap';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
@@ -10,11 +10,12 @@ import { ServersMap } from '../servers/data';
|
||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||
import './Home.scss';
|
||||
|
||||
export interface HomeProps extends RouteChildrenProps {
|
||||
export interface HomeProps {
|
||||
servers: ServersMap;
|
||||
}
|
||||
|
||||
const Home = ({ servers, history }: HomeProps) => {
|
||||
const Home = ({ servers }: HomeProps) => {
|
||||
const navigate = useNavigate();
|
||||
const serversList = values(servers);
|
||||
const hasServers = !isEmpty(serversList);
|
||||
|
||||
@@ -22,20 +23,22 @@ const Home = ({ servers, history }: HomeProps) => {
|
||||
// Try to redirect to the first server marked as auto-connect
|
||||
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
|
||||
|
||||
autoConnectServer && history.push(`/server/${autoConnectServer.id}`);
|
||||
autoConnectServer && navigate(`/server/${autoConnectServer.id}`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<Card className="home__main-card">
|
||||
<Row noGutters>
|
||||
<Row className="g-0">
|
||||
<div className="col-md-5 d-none d-md-block">
|
||||
<div className="p-4">
|
||||
<ShlinkLogo />
|
||||
<div className="home__logo-wrapper">
|
||||
<div className="home__logo">
|
||||
<ShlinkLogo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-7 home__servers-container">
|
||||
<div className="p-4">
|
||||
<div className="home__title-wrapper">
|
||||
<h1 className="home__title">Welcome!</h1>
|
||||
</div>
|
||||
<ServersListGroup embedded servers={serversList}>
|
||||
@@ -43,14 +46,14 @@ const Home = ({ servers, history }: HomeProps) => {
|
||||
<div className="p-4 text-center">
|
||||
<p className="mb-5">This application will help you manage your Shlink servers.</p>
|
||||
<p>
|
||||
<Link to="/server/create" className="btn btn-outline-primary btn-lg mr-2">
|
||||
<FontAwesomeIcon icon={faPlus} /> <span className="ml-1">Add a server</span>
|
||||
<Link to="/server/create" className="btn btn-outline-primary btn-lg me-2">
|
||||
<FontAwesomeIcon icon={faPlus} /> <span className="ms-1">Add a server</span>
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mb-0 mt-5">
|
||||
<ExternalLink href="https://shlink.io/documentation">
|
||||
<small>
|
||||
<span className="mr-1">Learn more about Shlink</span> <FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span className="me-1">Learn more about Shlink</span> <FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
</small>
|
||||
</ExternalLink>
|
||||
</p>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { FC, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||
import classNames from 'classnames';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||
import './MainHeader.scss';
|
||||
|
||||
const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps) => {
|
||||
const MainHeader = (ServersDropdown: FC) => () => {
|
||||
const [ isOpen, toggleOpen, , close ] = useToggle();
|
||||
const location = useLocation();
|
||||
const { pathname } = location;
|
||||
|
||||
useEffect(close, [ location ]);
|
||||
@@ -29,9 +29,9 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
|
||||
</NavbarToggler>
|
||||
|
||||
<Collapse navbar isOpen={isOpen}>
|
||||
<Nav navbar className="ml-auto">
|
||||
<Nav navbar className="ms-auto">
|
||||
<NavItem>
|
||||
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
||||
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
|
||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.menu-layout__swipeable {
|
||||
$offset: 15px;
|
||||
|
||||
height: 100%;
|
||||
margin-right: -$offset;
|
||||
margin-left: -$offset;
|
||||
padding-left: $offset;
|
||||
padding-right: $offset;
|
||||
}
|
||||
|
||||
.menu-layout__swipeable-inner {
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
|
||||
import { supportsDomainRedirects, supportsOrphanVisits } from '../utils/helpers/features';
|
||||
import { supportsDomainRedirects, supportsNonOrphanVisits, supportsOrphanVisits } from '../utils/helpers/features';
|
||||
import { isReachableServer } from '../servers/data';
|
||||
import NotFound from './NotFound';
|
||||
import { AsideMenuProps } from './AsideMenu';
|
||||
import './MenuLayout.scss';
|
||||
|
||||
interface MenuLayoutProps {
|
||||
sidebarPresent: Function;
|
||||
sidebarNotPresent: Function;
|
||||
}
|
||||
|
||||
const MenuLayout = (
|
||||
TagsList: FC,
|
||||
ShortUrlsList: FC,
|
||||
@@ -19,20 +24,29 @@ const MenuLayout = (
|
||||
ShortUrlVisits: FC,
|
||||
TagVisits: FC,
|
||||
OrphanVisits: FC,
|
||||
NonOrphanVisits: FC,
|
||||
ServerError: FC,
|
||||
Overview: FC,
|
||||
EditShortUrl: FC,
|
||||
ManageDomains: FC,
|
||||
) => withSelectedServer(({ location, selectedServer }) => {
|
||||
) => withSelectedServer<MenuLayoutProps>(({ selectedServer, sidebarNotPresent, sidebarPresent }) => {
|
||||
const location = useLocation();
|
||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
||||
const showContent = isReachableServer(selectedServer);
|
||||
|
||||
useEffect(() => hideSidebar(), [ location ]);
|
||||
useEffect(() => {
|
||||
showContent && sidebarPresent();
|
||||
|
||||
if (!isReachableServer(selectedServer)) {
|
||||
return () => sidebarNotPresent();
|
||||
}, []);
|
||||
|
||||
if (!showContent) {
|
||||
return <ServerError />;
|
||||
}
|
||||
|
||||
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
|
||||
const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer);
|
||||
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
|
||||
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
||||
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
||||
@@ -46,21 +60,23 @@ const MenuLayout = (
|
||||
<AsideMenu selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
||||
<div className="menu-layout__container" onClick={() => hideSidebar()}>
|
||||
<div className="container-xl">
|
||||
<Switch>
|
||||
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
|
||||
<Route exact path="/server/:serverId/overview" component={Overview} />
|
||||
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrlsList} />
|
||||
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
||||
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||
<Route path="/server/:serverId/short-code/:shortCode/edit" component={EditShortUrl} />
|
||||
<Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />
|
||||
{addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />}
|
||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||
{addManageDomainsRoute && <Route exact path="/server/:serverId/manage-domains" component={ManageDomains} />}
|
||||
<Routes>
|
||||
<Route index element={<Navigate replace to="overview" />} />
|
||||
<Route path="/overview" element={<Overview />} />
|
||||
<Route path="/list-short-urls/:page" element={<ShortUrlsList />} />
|
||||
<Route path="/create-short-url" element={<CreateShortUrl />} />
|
||||
<Route path="/short-code/:shortCode/visits/*" element={<ShortUrlVisits />} />
|
||||
<Route path="/short-code/:shortCode/edit" element={<EditShortUrl />} />
|
||||
<Route path="/tag/:tag/visits/*" element={<TagVisits />} />
|
||||
{addOrphanVisitsRoute && <Route path="/orphan-visits/*" element={<OrphanVisits />} />}
|
||||
{addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />}
|
||||
<Route path="/manage-tags" element={<TagsList />} />
|
||||
{addManageDomainsRoute && <Route path="/manage-domains" element={<ManageDomains />} />}
|
||||
<Route
|
||||
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||
path="*"
|
||||
element={<NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||
/>
|
||||
</Switch>
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { FC } from 'react';
|
||||
import './NoMenuLayout.scss';
|
||||
|
||||
const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper container-xl">{children}</div>;
|
||||
|
||||
export default NoMenuLayout;
|
||||
export const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper container-xl">{children}</div>;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { PropsWithChildren, useEffect } from 'react';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
import { FC, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
const ScrollToTop = (): FC => ({ children }) => {
|
||||
const location = useLocation();
|
||||
|
||||
const ScrollToTop = () => ({ location, children }: PropsWithChildren<RouteComponentProps>) => {
|
||||
useEffect(() => {
|
||||
scrollTo(0, 0);
|
||||
}, [ location ]);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { pipe } from 'ramda';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
||||
import { isReachableServer } from '../servers/data';
|
||||
import { ShlinkVersionsContainerProps } from './ShlinkVersionsContainer';
|
||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||
|
||||
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
||||
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
|
||||
|
||||
export interface ShlinkVersionsProps extends ShlinkVersionsContainerProps {
|
||||
export interface ShlinkVersionsProps {
|
||||
selectedServer: SelectedServer;
|
||||
clientVersion?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.shlink-versions-container--with-server {
|
||||
.shlink-versions-container--with-sidebar {
|
||||
margin-left: 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import classNames from 'classnames';
|
||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
import ShlinkVersions from './ShlinkVersions';
|
||||
import { Sidebar } from './reducers/sidebar';
|
||||
import './ShlinkVersionsContainer.scss';
|
||||
|
||||
export interface ShlinkVersionsContainerProps {
|
||||
selectedServer: SelectedServer;
|
||||
sidebar: Sidebar;
|
||||
}
|
||||
|
||||
const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => {
|
||||
const ShlinkVersionsContainer = ({ selectedServer, sidebar }: ShlinkVersionsContainerProps) => {
|
||||
const classes = classNames('text-center', {
|
||||
'shlink-versions-container--with-server': isReachableServer(selectedServer),
|
||||
'shlink-versions-container--with-sidebar': sidebar.sidebarPresent,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
27
src/common/reducers/sidebar.ts
Normal file
27
src/common/reducers/sidebar.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Action } from 'redux';
|
||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const SIDEBAR_PRESENT = 'shlink/common/SIDEBAR_PRESENT';
|
||||
export const SIDEBAR_NOT_PRESENT = 'shlink/common/SIDEBAR_NOT_PRESENT';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export interface Sidebar {
|
||||
sidebarPresent: boolean;
|
||||
}
|
||||
|
||||
type SidebarRenderedAction = Action<string>;
|
||||
type SidebarNotRenderedAction = Action<string>;
|
||||
|
||||
const initialState: Sidebar = {
|
||||
sidebarPresent: false,
|
||||
};
|
||||
|
||||
export default buildReducer<Sidebar, SidebarRenderedAction & SidebarNotRenderedAction>({
|
||||
[SIDEBAR_PRESENT]: () => ({ sidebarPresent: true }),
|
||||
[SIDEBAR_NOT_PRESENT]: () => ({ sidebarPresent: false }),
|
||||
}, initialState);
|
||||
|
||||
export const sidebarPresent = buildActionCreator(SIDEBAR_PRESENT);
|
||||
|
||||
export const sidebarNotPresent = buildActionCreator(SIDEBAR_NOT_PRESENT);
|
||||
33
src/common/services/ReportExporter.ts
Normal file
33
src/common/services/ReportExporter.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { CsvJson } from 'csvjson';
|
||||
import { NormalizedVisit } from '../../visits/types';
|
||||
import { ExportableShortUrl } from '../../short-urls/data';
|
||||
import { saveCsv } from '../../utils/helpers/files';
|
||||
|
||||
export class ReportExporter {
|
||||
public constructor(
|
||||
private readonly window: Window,
|
||||
private readonly csvjson: CsvJson,
|
||||
) {}
|
||||
|
||||
public readonly exportVisits = (filename: string, visits: NormalizedVisit[]) => {
|
||||
if (!visits.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.exportCsv(filename, visits);
|
||||
};
|
||||
|
||||
public readonly exportShortUrls = (shortUrls: ExportableShortUrl[]) => {
|
||||
if (!shortUrls.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.exportCsv('short_urls.csv', shortUrls);
|
||||
};
|
||||
|
||||
private readonly exportCsv = (filename: string, rows: object[]) => {
|
||||
const csv = this.csvjson.toCSV(rows, { headers: 'key', wrap: true });
|
||||
|
||||
saveCsv(this.window, csv, filename);
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import Bottle, { Decorator } from 'bottlejs';
|
||||
import Bottle from 'bottlejs';
|
||||
import ScrollToTop from '../ScrollToTop';
|
||||
import MainHeader from '../MainHeader';
|
||||
import Home from '../Home';
|
||||
@@ -9,26 +9,26 @@ import ErrorHandler from '../ErrorHandler';
|
||||
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||
import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar';
|
||||
import { ImageDownloader } from './ImageDownloader';
|
||||
import { ReportExporter } from './ReportExporter';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Services
|
||||
bottle.constant('window', (global as any).window);
|
||||
bottle.constant('console', global.console);
|
||||
bottle.constant('axios', axios);
|
||||
|
||||
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
|
||||
bottle.service('ReportExporter', ReportExporter, 'window', 'csvjson');
|
||||
|
||||
// Components
|
||||
bottle.serviceFactory('ScrollToTop', ScrollToTop);
|
||||
bottle.decorator('ScrollToTop', withRouter);
|
||||
|
||||
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
|
||||
bottle.decorator('MainHeader', withRouter);
|
||||
|
||||
bottle.serviceFactory('Home', () => Home);
|
||||
bottle.decorator('Home', withoutSelectedServer);
|
||||
bottle.decorator('Home', withRouter);
|
||||
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
|
||||
|
||||
bottle.serviceFactory(
|
||||
@@ -41,20 +41,24 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||
'ShortUrlVisits',
|
||||
'TagVisits',
|
||||
'OrphanVisits',
|
||||
'NonOrphanVisits',
|
||||
'ServerError',
|
||||
'Overview',
|
||||
'EditShortUrl',
|
||||
'ManageDomains',
|
||||
);
|
||||
bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer' ]));
|
||||
bottle.decorator('MenuLayout', withRouter);
|
||||
bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer', 'sidebarPresent', 'sidebarNotPresent' ]));
|
||||
|
||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||
|
||||
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
|
||||
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer' ]));
|
||||
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer', 'sidebar' ]));
|
||||
|
||||
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('sidebarPresent', () => sidebarPresent);
|
||||
bottle.serviceFactory('sidebarNotPresent', () => sidebarNotPresent);
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Bottle, { IContainer } from 'bottlejs';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { connect as reduxConnect } from 'react-redux';
|
||||
import { pick } from 'ramda';
|
||||
import provideApiServices from '../api/services/provideServices';
|
||||
@@ -18,7 +17,8 @@ import { ConnectDecorator } from './types';
|
||||
type LazyActionMap = Record<string, Function>;
|
||||
|
||||
const bottle = new Bottle();
|
||||
const { container } = bottle;
|
||||
|
||||
export const { container } = bottle;
|
||||
|
||||
const lazyService = <T extends Function, K>(container: IContainer, serviceName: string) =>
|
||||
(...args: any[]) => (container[serviceName] as T)(...args) as K;
|
||||
@@ -33,16 +33,14 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
|
||||
actionServiceNames.reduce(mapActionService, {}),
|
||||
);
|
||||
|
||||
provideAppServices(bottle, connect, withRouter);
|
||||
provideCommonServices(bottle, connect, withRouter);
|
||||
provideAppServices(bottle, connect);
|
||||
provideCommonServices(bottle, connect);
|
||||
provideApiServices(bottle);
|
||||
provideShortUrlsServices(bottle, connect, withRouter);
|
||||
provideServersServices(bottle, connect, withRouter);
|
||||
provideShortUrlsServices(bottle, connect);
|
||||
provideServersServices(bottle, connect);
|
||||
provideTagsServices(bottle, connect);
|
||||
provideVisitsServices(bottle, connect);
|
||||
provideUtilsServices(bottle);
|
||||
provideMercureServices(bottle);
|
||||
provideSettingsServices(bottle, connect);
|
||||
provideDomainsServices(bottle, connect);
|
||||
|
||||
export default container;
|
||||
|
||||
@@ -2,6 +2,8 @@ import ReduxThunk from 'redux-thunk';
|
||||
import { applyMiddleware, compose, createStore } from 'redux';
|
||||
import { save, load, RLSOptions } from 'redux-localstorage-simple';
|
||||
import reducers from '../reducers';
|
||||
import { migrateDeprecatedSettings } from '../settings/helpers';
|
||||
import { ShlinkState } from './types';
|
||||
|
||||
const isProduction = process.env.NODE_ENV !== 'production';
|
||||
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||
@@ -12,9 +14,8 @@ const localStorageConfig: RLSOptions = {
|
||||
namespaceSeparator: '.',
|
||||
debounce: 300,
|
||||
};
|
||||
const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState);
|
||||
|
||||
const store = createStore(reducers, load(localStorageConfig), composeEnhancers(
|
||||
export const store = createStore(reducers, preloadedState, composeEnhancers(
|
||||
applyMiddleware(save(localStorageConfig), ReduxThunk),
|
||||
));
|
||||
|
||||
export default store;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Settings } from '../settings/reducers/settings';
|
||||
import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
|
||||
import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
|
||||
import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
|
||||
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
|
||||
import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
|
||||
import { TagDeletion } from '../tags/reducers/tagDelete';
|
||||
import { TagEdition } from '../tags/reducers/tagEdit';
|
||||
@@ -15,18 +14,19 @@ import { TagVisits } from '../visits/reducers/tagVisits';
|
||||
import { DomainsList } from '../domains/reducers/domainsList';
|
||||
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||
import { VisitsInfo } from '../visits/types';
|
||||
import { Sidebar } from '../common/reducers/sidebar';
|
||||
|
||||
export interface ShlinkState {
|
||||
servers: ServersMap;
|
||||
selectedServer: SelectedServer;
|
||||
shortUrlsList: ShortUrlsList;
|
||||
shortUrlsListParams: ShortUrlsListParams;
|
||||
shortUrlCreationResult: ShortUrlCreation;
|
||||
shortUrlDeletion: ShortUrlDeletion;
|
||||
shortUrlEdition: ShortUrlEdition;
|
||||
shortUrlVisits: ShortUrlVisits;
|
||||
tagVisits: TagVisits;
|
||||
orphanVisits: VisitsInfo;
|
||||
nonOrphanVisits: VisitsInfo;
|
||||
shortUrlDetail: ShortUrlDetail;
|
||||
tagsList: TagsList;
|
||||
tagDelete: TagDeletion;
|
||||
@@ -36,6 +36,7 @@ export interface ShlinkState {
|
||||
domainsList: DomainsList;
|
||||
visitsOverview: VisitsOverview;
|
||||
appUpdated: boolean;
|
||||
sidebar: Sidebar;
|
||||
}
|
||||
|
||||
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { FC } from 'react';
|
||||
import { FC, useEffect } from 'react';
|
||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBan as forbiddenIcon,
|
||||
faCheck as defaultDomainIcon,
|
||||
faDotCircle as defaultDomainIcon,
|
||||
faEdit as editIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { ShlinkDomain, ShlinkDomainRedirects } from '../api/types';
|
||||
import { ShlinkDomainRedirects } from '../api/types';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { OptionalString } from '../utils/utils';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
import { supportsDefaultDomainRedirectsEdition } from '../utils/helpers/features';
|
||||
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
|
||||
import { Domain } from './data';
|
||||
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
|
||||
|
||||
interface DomainRowProps {
|
||||
domain: ShlinkDomain;
|
||||
domain: Domain;
|
||||
defaultRedirects?: ShlinkDomainRedirects;
|
||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||
checkDomainHealth: (domain: string) => void;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
|
||||
@@ -30,13 +36,20 @@ const DefaultDomain: FC = () => (
|
||||
</>
|
||||
);
|
||||
|
||||
export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, defaultRedirects }) => {
|
||||
export const DomainRow: FC<DomainRowProps> = (
|
||||
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer },
|
||||
) => {
|
||||
const [ isOpen, toggle ] = useToggle();
|
||||
const { domain: authority, isDefault, redirects } = domain;
|
||||
const { domain: authority, isDefault, redirects, status } = domain;
|
||||
const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer);
|
||||
|
||||
useEffect(() => {
|
||||
checkDomainHealth(domain.domain);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<tr className="responsive-table__row">
|
||||
<td className="responsive-table__cell" data-th="Is default domain">{isDefault ? <DefaultDomain /> : ''}</td>
|
||||
<td className="responsive-table__cell" data-th="Is default domain">{isDefault && <DefaultDomain />}</td>
|
||||
<th className="responsive-table__cell" data-th="Domain">{authority}</th>
|
||||
<td className="responsive-table__cell" data-th="Base path redirect">
|
||||
{redirects?.baseUrlRedirect ?? <Nr fallback={defaultRedirects?.baseUrlRedirect} />}
|
||||
@@ -47,13 +60,16 @@ export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, def
|
||||
<td className="responsive-table__cell" data-th="Invalid short URL redirect">
|
||||
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
|
||||
</td>
|
||||
<td className="responsive-table__cell text-right">
|
||||
<span id={isDefault ? 'defaultDomainBtn' : undefined}>
|
||||
<Button outline size="sm" disabled={isDefault} onClick={isDefault ? undefined : toggle}>
|
||||
<FontAwesomeIcon fixedWidth icon={isDefault ? forbiddenIcon : editIcon} />
|
||||
<td className="responsive-table__cell text-lg-center" data-th="Status">
|
||||
<DomainStatusIcon status={status} />
|
||||
</td>
|
||||
<td className="responsive-table__cell text-end">
|
||||
<span id={!canEditDomain ? 'defaultDomainBtn' : undefined}>
|
||||
<Button outline size="sm" disabled={!canEditDomain} onClick={!canEditDomain ? undefined : toggle}>
|
||||
<FontAwesomeIcon fixedWidth icon={!canEditDomain ? forbiddenIcon : editIcon} />
|
||||
</Button>
|
||||
</span>
|
||||
{isDefault && (
|
||||
{!canEditDomain && (
|
||||
<UncontrolledTooltip target="defaultDomainBtn" placement="left">
|
||||
Redirects for default domain cannot be edited here.
|
||||
<br />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button, DropdownItem, Input, InputGroup, InputGroupAddon, UncontrolledTooltip } from 'reactstrap';
|
||||
import { InputProps } from 'reactstrap/lib/Input';
|
||||
import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip, InputProps } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUndo } from '@fortawesome/free-solid-svg-icons';
|
||||
import { isEmpty, pipe } from 'ramda';
|
||||
@@ -32,24 +31,22 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
|
||||
return inputDisplayed ? (
|
||||
<InputGroup>
|
||||
<Input
|
||||
value={value}
|
||||
value={value ?? ''}
|
||||
placeholder="Domain"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<InputGroupAddon addonType="append">
|
||||
<Button
|
||||
id="backToDropdown"
|
||||
outline
|
||||
type="button"
|
||||
className="domains-dropdown__back-btn"
|
||||
onClick={pipe(unselectDomain, hideInput)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUndo} />
|
||||
</Button>
|
||||
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
|
||||
Existing domains
|
||||
</UncontrolledTooltip>
|
||||
</InputGroupAddon>
|
||||
<Button
|
||||
id="backToDropdown"
|
||||
outline
|
||||
type="button"
|
||||
className="domains-dropdown__back-btn"
|
||||
onClick={pipe(unselectDomain, hideInput)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUndo} />
|
||||
</Button>
|
||||
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
|
||||
Existing domains
|
||||
</UncontrolledTooltip>
|
||||
</InputGroup>
|
||||
) : (
|
||||
<DropdownBtn
|
||||
@@ -63,7 +60,7 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
|
||||
onClick={() => onChange(domain)}
|
||||
>
|
||||
{domain}
|
||||
{isDefault && <span className="float-right text-muted">default</span>}
|
||||
{isDefault && <span className="float-end text-muted">default</span>}
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownItem divider />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { ShlinkDomainRedirects } from '../api/types';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
import { DomainsList } from './reducers/domainsList';
|
||||
import { DomainRow } from './DomainRow';
|
||||
|
||||
@@ -12,16 +13,18 @@ interface ManageDomainsProps {
|
||||
listDomains: Function;
|
||||
filterDomains: (searchTerm: string) => void;
|
||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||
checkDomainHealth: (domain: string) => void;
|
||||
domainsList: DomainsList;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '' ];
|
||||
const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', '' ];
|
||||
|
||||
export const ManageDomains: FC<ManageDomainsProps> = (
|
||||
{ listDomains, domainsList, filterDomains, editDomainRedirects },
|
||||
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer },
|
||||
) => {
|
||||
const { filteredDomains: domains, loading, error, errorData } = domainsList;
|
||||
const defaultRedirects = domains.find(({ isDefault }) => isDefault)?.redirects;
|
||||
const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList;
|
||||
const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
|
||||
|
||||
useEffect(() => {
|
||||
listDomains();
|
||||
@@ -42,7 +45,7 @@ export const ManageDomains: FC<ManageDomainsProps> = (
|
||||
|
||||
return (
|
||||
<SimpleCard>
|
||||
<table className="table table-hover mb-0">
|
||||
<table className="table table-hover responsive-table mb-0">
|
||||
<thead className="responsive-table__header">
|
||||
<tr>{headers.map((column, index) => <th key={index}>{column}</th>)}</tr>
|
||||
</thead>
|
||||
@@ -53,7 +56,9 @@ export const ManageDomains: FC<ManageDomainsProps> = (
|
||||
key={domain.domain}
|
||||
domain={domain}
|
||||
editDomainRedirects={editDomainRedirects}
|
||||
defaultRedirects={defaultRedirects}
|
||||
checkDomainHealth={checkDomainHealth}
|
||||
defaultRedirects={resolvedDefaultRedirects}
|
||||
selectedServer={selectedServer}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
7
src/domains/data/index.ts
Normal file
7
src/domains/data/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { ShlinkDomain } from '../../api/types';
|
||||
|
||||
export type DomainStatus = 'validating' | 'valid' | 'invalid';
|
||||
|
||||
export interface Domain extends ShlinkDomain {
|
||||
status: DomainStatus;
|
||||
}
|
||||
62
src/domains/helpers/DomainStatusIcon.tsx
Normal file
62
src/domains/helpers/DomainStatusIcon.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { FC, useEffect, useRef, useState } from 'react';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faTimes as invalidIcon,
|
||||
faCheck as checkIcon,
|
||||
faCircleNotch as loadingStatusIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { MediaMatcher } from '../../utils/types';
|
||||
import { DomainStatus } from '../data';
|
||||
|
||||
interface DomainStatusIconProps {
|
||||
status: DomainStatus;
|
||||
matchMedia?: MediaMatcher;
|
||||
}
|
||||
|
||||
export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => {
|
||||
const ref = useRef<HTMLSpanElement>();
|
||||
const matchesMobile = () => matchMedia('(max-width: 991px)').matches;
|
||||
const [ isMobile, setIsMobile ] = useState<boolean>(matchesMobile());
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => setIsMobile(matchesMobile());
|
||||
|
||||
window.addEventListener('resize', listener);
|
||||
|
||||
return () => window.removeEventListener('resize', listener);
|
||||
}, []);
|
||||
|
||||
if (status === 'validating') {
|
||||
return <FontAwesomeIcon fixedWidth icon={loadingStatusIcon} spin />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={(el: HTMLSpanElement) => {
|
||||
ref.current = el;
|
||||
}}
|
||||
>
|
||||
{status === 'valid'
|
||||
? <FontAwesomeIcon fixedWidth icon={checkIcon} className="text-muted" />
|
||||
: <FontAwesomeIcon fixedWidth icon={invalidIcon} className="text-danger" />}
|
||||
</span>
|
||||
<UncontrolledTooltip
|
||||
target={(() => ref.current) as any}
|
||||
placement={isMobile ? 'top-start' : 'left'}
|
||||
autohide={status === 'valid'}
|
||||
>
|
||||
{status === 'valid' ? 'Congratulations! This domain is properly configured.' : (
|
||||
<span>
|
||||
Oops! There is some missing configuration, and short URLs shared with this domain will not work.
|
||||
<br />
|
||||
Check the <ExternalLink href="https://slnk.to/multi-domain-docs">documentation</ExternalLink> in order to
|
||||
find out what is missing.
|
||||
</span>
|
||||
)}
|
||||
</UncontrolledTooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
|
||||
import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer';
|
||||
import { InputFormGroup, InputFormGroupProps } from '../../utils/forms/InputFormGroup';
|
||||
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
|
||||
import { InfoTooltip } from '../../utils/InfoTooltip';
|
||||
|
||||
@@ -12,8 +12,8 @@ interface EditDomainRedirectsModalProps {
|
||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||
}
|
||||
|
||||
const FormGroup: FC<FormGroupContainerProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
|
||||
<FormGroupContainer
|
||||
const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
|
||||
<InputFormGroup
|
||||
{...rest}
|
||||
required={false}
|
||||
type="url"
|
||||
@@ -42,20 +42,20 @@ export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
|
||||
<ModalHeader toggle={toggle}>Edit redirects for <b>{domain.domain}</b></ModalHeader>
|
||||
<ModalBody>
|
||||
<FormGroup value={baseUrlRedirect} onChange={setBaseUrlRedirect}>
|
||||
<InfoTooltip className="mr-2" placement="bottom">
|
||||
<InfoTooltip className="me-2" placement="bottom">
|
||||
Visitors accessing the base url, as in <b>https://{domain.domain}/</b>, will be redirected to this URL.
|
||||
</InfoTooltip>
|
||||
Base URL
|
||||
</FormGroup>
|
||||
<FormGroup value={regular404Redirect} onChange={setRegular404Redirect}>
|
||||
<InfoTooltip className="mr-2" placement="bottom">
|
||||
<InfoTooltip className="me-2" placement="bottom">
|
||||
Visitors accessing a url not matching a short URL pattern, as in <b>https://{domain.domain}/???/[...]</b>,
|
||||
will be redirected to this URL.
|
||||
</InfoTooltip>
|
||||
Regular 404
|
||||
</FormGroup>
|
||||
<FormGroup value={invalidShortUrlRedirect} isLast onChange={setInvalidShortUrlRedirect}>
|
||||
<InfoTooltip className="mr-2" placement="bottom">
|
||||
<InfoTooltip className="me-2" placement="bottom">
|
||||
Visitors accessing a url matching a short URL pattern, but not matching an existing short code, will be
|
||||
redirected to this URL.
|
||||
</InfoTooltip>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { ProblemDetailsError, ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
|
||||
import { ProblemDetailsError, ShlinkDomainRedirects } from '../../api/types';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { GetState } from '../../container/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { Domain, DomainStatus } from '../data';
|
||||
import { hasServerData } from '../../servers/data';
|
||||
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
|
||||
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
@@ -12,24 +15,32 @@ export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
|
||||
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
|
||||
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
|
||||
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
|
||||
export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export interface DomainsList {
|
||||
domains: ShlinkDomain[];
|
||||
filteredDomains: ShlinkDomain[];
|
||||
domains: Domain[];
|
||||
filteredDomains: Domain[];
|
||||
defaultRedirects?: ShlinkDomainRedirects;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface ListDomainsAction extends Action<string> {
|
||||
domains: ShlinkDomain[];
|
||||
domains: Domain[];
|
||||
defaultRedirects?: ShlinkDomainRedirects;
|
||||
}
|
||||
|
||||
interface FilterDomainsAction extends Action<string> {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
interface ValidateDomain extends Action<string> {
|
||||
domain: string;
|
||||
status: DomainStatus;
|
||||
}
|
||||
|
||||
const initialState: DomainsList = {
|
||||
domains: [],
|
||||
filteredDomains: [],
|
||||
@@ -40,15 +51,20 @@ const initialState: DomainsList = {
|
||||
export type DomainsCombinedAction = ListDomainsAction
|
||||
& ApiErrorAction
|
||||
& FilterDomainsAction
|
||||
& EditDomainRedirectsAction;
|
||||
& EditDomainRedirectsAction
|
||||
& ValidateDomain;
|
||||
|
||||
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
|
||||
(d: ShlinkDomain): ShlinkDomain => d.domain !== domain ? d : { ...d, redirects };
|
||||
(d: Domain): Domain => d.domain !== domain ? d : { ...d, redirects };
|
||||
|
||||
export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
|
||||
(d: Domain): Domain => d.domain !== domain ? d : { ...d, status };
|
||||
|
||||
export default buildReducer<DomainsList, DomainsCombinedAction>({
|
||||
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
||||
[LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }),
|
||||
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains, filteredDomains: domains }),
|
||||
[LIST_DOMAINS]: (_, { domains, defaultRedirects }) =>
|
||||
({ ...initialState, domains, filteredDomains: domains, defaultRedirects }),
|
||||
[FILTER_DOMAINS]: (state, { searchTerm }) => ({
|
||||
...state,
|
||||
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)),
|
||||
@@ -58,6 +74,11 @@ export default buildReducer<DomainsList, DomainsCombinedAction>({
|
||||
domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||
}),
|
||||
[VALIDATE_DOMAIN]: (state, { domain, status }) => ({
|
||||
...state,
|
||||
domains: state.domains.map(replaceStatusOnDomain(domain, status)),
|
||||
filteredDomains: state.filteredDomains.map(replaceStatusOnDomain(domain, status)),
|
||||
}),
|
||||
}, initialState);
|
||||
|
||||
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
||||
@@ -68,12 +89,42 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
|
||||
const { listDomains } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
const domains = await listDomains();
|
||||
const { domains, defaultRedirects } = await listDomains().then(({ data, defaultRedirects }) => ({
|
||||
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
|
||||
defaultRedirects,
|
||||
}));
|
||||
|
||||
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
|
||||
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains, defaultRedirects });
|
||||
} catch (e: any) {
|
||||
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
||||
}
|
||||
};
|
||||
|
||||
export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm });
|
||||
|
||||
export const checkDomainHealth = (buildShlinkApiClient: ShlinkApiClientBuilder) => (domain: string) => async (
|
||||
dispatch: Dispatch,
|
||||
getState: GetState,
|
||||
) => {
|
||||
const { selectedServer } = getState();
|
||||
|
||||
if (!hasServerData(selectedServer)) {
|
||||
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { url, ...rest } = selectedServer;
|
||||
const { health } = buildShlinkApiClient({
|
||||
...rest,
|
||||
url: replaceAuthorityFromUri(url, domain),
|
||||
});
|
||||
|
||||
const { status } = await health();
|
||||
|
||||
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: status === 'pass' ? 'valid' : 'invalid' });
|
||||
} catch (e) {
|
||||
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Bottle from 'bottlejs';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { filterDomains, listDomains } from '../reducers/domainsList';
|
||||
import { checkDomainHealth, filterDomains, listDomains } from '../reducers/domainsList';
|
||||
import { DomainSelector } from '../DomainSelector';
|
||||
import { ManageDomains } from '../ManageDomains';
|
||||
import { editDomainRedirects } from '../reducers/domainRedirects';
|
||||
@@ -12,14 +12,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
|
||||
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
||||
bottle.decorator('ManageDomains', connect(
|
||||
[ 'domainsList' ],
|
||||
[ 'listDomains', 'filterDomains', 'editDomainRedirects' ],
|
||||
[ 'domainsList', 'selectedServer' ],
|
||||
[ 'listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth' ],
|
||||
));
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('filterDomains', () => filterDomains);
|
||||
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('checkDomainHealth', checkDomainHealth, 'buildShlinkApiClient');
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@@ -19,6 +23,17 @@ body,
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
a,
|
||||
.btn-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* stylelint-disable-next-line selector-max-pseudo-class */
|
||||
a:not(.nav-link):not(.navbar-brand):not(.page-link):not(.highlight-card):not(.btn):not(.dropdown-item):hover,
|
||||
.btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.bg-main {
|
||||
background-color: $mainColor !important;
|
||||
}
|
||||
@@ -74,7 +89,8 @@ hr {
|
||||
border-color: var(--table-border-color);
|
||||
}
|
||||
|
||||
.page-link:hover {
|
||||
.page-link:hover,
|
||||
.page-link:focus {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
@@ -98,6 +114,22 @@ hr {
|
||||
}
|
||||
}
|
||||
|
||||
/* Deprecated. Brought from bootstrap 4 */
|
||||
.btn-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-primary:hover,
|
||||
.btn-primary:active,
|
||||
.btn-primary.active,
|
||||
.btn-outline-primary:hover,
|
||||
.btn-outline-primary:active,
|
||||
.btn-outline-primary.active, {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.dropdown-item,
|
||||
.dropdown-item-text {
|
||||
color: var(--text-color);
|
||||
@@ -133,10 +165,15 @@ hr {
|
||||
.close,
|
||||
.close:hover,
|
||||
.table,
|
||||
.table-hover tbody tr:hover {
|
||||
.table-hover > tbody > tr:hover > *,
|
||||
.table-hover > tbody > tr > * {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
filter: var(--btn-close-filter);
|
||||
}
|
||||
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { render } from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { homepage } from '../package.json';
|
||||
import container from './container';
|
||||
import store from './container/store';
|
||||
import { container } from './container';
|
||||
import { store } from './container/store';
|
||||
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
||||
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import { pipe } from 'ramda';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { CreateVisit } from '../../visits/types';
|
||||
import { MercureInfo } from '../reducers/mercureInfo';
|
||||
import { bindToMercureTopic } from './index';
|
||||
@@ -12,17 +13,19 @@ export interface MercureBoundProps {
|
||||
|
||||
export function boundToMercureHub<T = {}>(
|
||||
WrappedComponent: FC<MercureBoundProps & T>,
|
||||
getTopicsForProps: (props: T) => string[],
|
||||
getTopicsForProps: (props: T, routeParams: any) => string[],
|
||||
) {
|
||||
const pendingUpdates = new Set<CreateVisit>();
|
||||
|
||||
return (props: MercureBoundProps & T) => {
|
||||
const { createNewVisits, loadMercureInfo, mercureInfo } = props;
|
||||
const { interval } = mercureInfo;
|
||||
const params = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]);
|
||||
const closeEventSource = bindToMercureTopic(mercureInfo, getTopicsForProps(props), onMessage, loadMercureInfo);
|
||||
const topics = getTopicsForProps(props, params);
|
||||
const closeEventSource = bindToMercureTopic(mercureInfo, topics, onMessage, loadMercureInfo);
|
||||
|
||||
if (!interval) {
|
||||
return closeEventSource;
|
||||
|
||||
@@ -2,13 +2,13 @@ import { combineReducers } from 'redux';
|
||||
import serversReducer from '../servers/reducers/servers';
|
||||
import selectedServerReducer from '../servers/reducers/selectedServer';
|
||||
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
|
||||
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
|
||||
import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
|
||||
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
|
||||
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
||||
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
||||
import tagVisitsReducer from '../visits/reducers/tagVisits';
|
||||
import orphanVisitsReducer from '../visits/reducers/orphanVisits';
|
||||
import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits';
|
||||
import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail';
|
||||
import tagsListReducer from '../tags/reducers/tagsList';
|
||||
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
||||
@@ -18,19 +18,20 @@ import settingsReducer from '../settings/reducers/settings';
|
||||
import domainsListReducer from '../domains/reducers/domainsList';
|
||||
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
|
||||
import appUpdatesReducer from '../app/reducers/appUpdates';
|
||||
import sidebarReducer from '../common/reducers/sidebar';
|
||||
import { ShlinkState } from '../container/types';
|
||||
|
||||
export default combineReducers<ShlinkState>({
|
||||
servers: serversReducer,
|
||||
selectedServer: selectedServerReducer,
|
||||
shortUrlsList: shortUrlsListReducer,
|
||||
shortUrlsListParams: shortUrlsListParamsReducer,
|
||||
shortUrlCreationResult: shortUrlCreationReducer,
|
||||
shortUrlDeletion: shortUrlDeletionReducer,
|
||||
shortUrlEdition: shortUrlEditionReducer,
|
||||
shortUrlVisits: shortUrlVisitsReducer,
|
||||
tagVisits: tagVisitsReducer,
|
||||
orphanVisits: orphanVisitsReducer,
|
||||
nonOrphanVisits: nonOrphanVisitsReducer,
|
||||
shortUrlDetail: shortUrlDetailReducer,
|
||||
tagsList: tagsListReducer,
|
||||
tagDelete: tagDeleteReducer,
|
||||
@@ -40,4 +41,5 @@ export default combineReducers<ShlinkState>({
|
||||
domainsList: domainsListReducer,
|
||||
visitsOverview: visitsOverviewReducer,
|
||||
appUpdated: appUpdatesReducer,
|
||||
sidebar: sidebarReducer,
|
||||
});
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.create-server__label {
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
import { FC } from 'react';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { RouterProps } from 'react-router';
|
||||
import { Button } from 'reactstrap';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Result } from '../utils/Result';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||
import { StateFlagTimeout, useGoBack, useToggle } from '../utils/helpers/hooks';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||
import { ServerData, ServersMap, ServerWithId } from './data';
|
||||
import './CreateServer.scss';
|
||||
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
||||
|
||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||
|
||||
interface CreateServerProps extends RouterProps {
|
||||
interface CreateServerProps {
|
||||
createServer: (server: ServerWithId) => void;
|
||||
servers: ServersMap;
|
||||
}
|
||||
@@ -27,29 +27,52 @@ const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
||||
);
|
||||
|
||||
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
|
||||
{ servers, createServer, history: { push, goBack } }: CreateServerProps,
|
||||
{ servers, createServer }: CreateServerProps,
|
||||
) => {
|
||||
const navigate = useNavigate();
|
||||
const goBack = useGoBack();
|
||||
const hasServers = !!Object.keys(servers).length;
|
||||
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||
const handleSubmit = (serverData: ServerData) => {
|
||||
const [ isConfirmModalOpen, toggleConfirmModal ] = useToggle();
|
||||
const [ serverData, setServerData ] = useState<ServerData | undefined>();
|
||||
const save = () => {
|
||||
if (!serverData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = uuid();
|
||||
|
||||
createServer({ ...serverData, id });
|
||||
push(`/server/${id}`);
|
||||
navigate(`/server/${id}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const serverExists = Object.values(servers).some(
|
||||
({ url, apiKey }) => serverData?.url === url && serverData?.apiKey === apiKey,
|
||||
);
|
||||
|
||||
serverExists ? toggleConfirmModal() : save();
|
||||
}, [ serverData ]);
|
||||
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}>
|
||||
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={setServerData}>
|
||||
{!hasServers &&
|
||||
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />}
|
||||
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
|
||||
<Button outline color="primary" className="ml-2">Create server</Button>
|
||||
<Button outline color="primary" className="ms-2">Create server</Button>
|
||||
</ServerForm>
|
||||
|
||||
{serversImported && <ImportResult type="success" />}
|
||||
{errorImporting && <ImportResult type="error" />}
|
||||
|
||||
<DuplicatedServersModal
|
||||
isOpen={isConfirmModalOpen}
|
||||
duplicatedServers={serverData ? [ serverData ] : []}
|
||||
onDiscard={goBack}
|
||||
onSave={save}
|
||||
/>
|
||||
</NoMenuLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FC } from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { RouterProps } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ServerWithId } from './data';
|
||||
|
||||
export interface DeleteServerModalProps {
|
||||
@@ -10,17 +10,18 @@ export interface DeleteServerModalProps {
|
||||
redirectHome?: boolean;
|
||||
}
|
||||
|
||||
interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps {
|
||||
interface DeleteServerModalConnectProps extends DeleteServerModalProps {
|
||||
deleteServer: (server: ServerWithId) => void;
|
||||
}
|
||||
|
||||
const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
||||
{ server, toggle, isOpen, deleteServer, history, redirectHome = true },
|
||||
{ server, toggle, isOpen, deleteServer, redirectHome = true },
|
||||
) => {
|
||||
const navigate = useNavigate();
|
||||
const closeModal = () => {
|
||||
deleteServer(server);
|
||||
toggle();
|
||||
redirectHome && history.push('/');
|
||||
redirectHome && navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FC } from 'react';
|
||||
import { Button } from 'reactstrap';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||
import { useGoBack } from '../utils/helpers/hooks';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
import { withSelectedServer } from './helpers/withSelectedServer';
|
||||
import { isServerWithId, ServerData } from './data';
|
||||
@@ -9,9 +10,9 @@ interface EditServerProps {
|
||||
editServer: (serverId: string, serverData: ServerData) => void;
|
||||
}
|
||||
|
||||
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
|
||||
{ editServer, selectedServer, history: { goBack } },
|
||||
) => {
|
||||
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>(({ editServer, selectedServer }) => {
|
||||
const goBack = useGoBack();
|
||||
|
||||
if (!isServerWithId(selectedServer)) {
|
||||
return null;
|
||||
}
|
||||
@@ -28,7 +29,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
|
||||
initialValues={selectedServer}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<Button outline className="mr-2" onClick={goBack}>Cancel</Button>
|
||||
<Button outline className="me-2" onClick={goBack}>Cancel</Button>
|
||||
<Button outline color="primary">Save</Button>
|
||||
</ServerForm>
|
||||
</NoMenuLayout>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button, Row } from 'reactstrap';
|
||||
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Link } from 'react-router-dom';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { Result } from '../utils/Result';
|
||||
@@ -45,12 +45,12 @@ export const ManageServers = (
|
||||
<div className="col-md-6 d-flex d-md-block mb-2 mb-md-0">
|
||||
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
|
||||
{allServers.length > 0 && (
|
||||
<Button outline className="ml-2 flex-fill" onClick={async () => serversExporter.exportServers()}>
|
||||
<Button outline className="ms-2 flex-fill" onClick={async () => serversExporter.exportServers()}>
|
||||
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-md-6 text-md-right d-flex d-md-block">
|
||||
<div className="col-md-6 text-md-end d-flex d-md-block">
|
||||
<Button outline color="primary" className="flex-fill" tag={Link} to="/server/create">
|
||||
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
|
||||
</Button>
|
||||
@@ -58,7 +58,7 @@ export const ManageServers = (
|
||||
</Row>
|
||||
|
||||
<SimpleCard>
|
||||
<table className="table table-hover mb-0">
|
||||
<table className="table table-hover responsive-table mb-0">
|
||||
<thead className="responsive-table__header">
|
||||
<tr>
|
||||
{hasAutoConnect && <th style={{ width: '50px' }} />}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const ManageServersRow = (
|
||||
<Link to={`/server/${server.id}`}>{server.name}</Link>
|
||||
</th>
|
||||
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
|
||||
<td className="responsive-table__cell text-right">
|
||||
<td className="responsive-table__cell text-end">
|
||||
<ManageServersRowDropdown server={server} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.overview__card.overview__card {
|
||||
text-align: center;
|
||||
border-top: 3px solid var(--brand-color);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.overview__card-title {
|
||||
text-transform: uppercase;
|
||||
color: $textPlaceholder;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import { Card, CardBody, CardHeader, CardText, CardTitle, Row } from 'reactstrap';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
||||
import { Card, CardBody, CardHeader, Row } from 'reactstrap';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ITEMS_IN_OVERVIEW_PAGE, ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
||||
import { prettify } from '../utils/helpers/numbers';
|
||||
import { TagsList } from '../tags/reducers/tagsList';
|
||||
import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
|
||||
@@ -11,8 +11,9 @@ import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||
import { Versions } from '../utils/helpers/version';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import { ShlinkShortUrlsListParams } from '../api/types';
|
||||
import { supportsNonOrphanVisits, supportsOrphanVisits } from '../utils/helpers/features';
|
||||
import { getServerId, SelectedServer } from './data';
|
||||
import './Overview.scss';
|
||||
import { HighlightCard } from './helpers/HighlightCard';
|
||||
|
||||
interface OverviewConnectProps {
|
||||
shortUrlsList: ShortUrlsListState;
|
||||
@@ -41,10 +42,12 @@ export const Overview = (
|
||||
const { loading: loadingTags } = tagsList;
|
||||
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
|
||||
const serverId = getServerId(selectedServer);
|
||||
const history = useHistory();
|
||||
const linkToOrphanVisits = supportsOrphanVisits(selectedServer);
|
||||
const linkToNonOrphanVisits = supportsNonOrphanVisits(selectedServer);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
listShortUrls({ itemsPerPage: 5, orderBy: { dateCreated: 'DESC' } });
|
||||
listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } });
|
||||
listTags();
|
||||
loadVisitsOverview();
|
||||
}, []);
|
||||
@@ -52,45 +55,38 @@ export const Overview = (
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<div className="col-md-6 col-xl-3">
|
||||
<Card className="overview__card mb-3" body>
|
||||
<CardTitle tag="h5" className="overview__card-title">Visits</CardTitle>
|
||||
<CardText tag="h2">{loadingVisits ? 'Loading...' : prettify(visitsCount)}</CardText>
|
||||
</Card>
|
||||
<div className="col-lg-6 col-xl-3 mb-3">
|
||||
<HighlightCard title="Visits" link={linkToNonOrphanVisits && `/server/${serverId}/non-orphan-visits`}>
|
||||
{loadingVisits ? 'Loading...' : prettify(visitsCount)}
|
||||
</HighlightCard>
|
||||
</div>
|
||||
<div className="col-md-6 col-xl-3">
|
||||
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/orphan-visits`}>
|
||||
<CardTitle tag="h5" className="overview__card-title">Orphan visits</CardTitle>
|
||||
<CardText tag="h2">
|
||||
<ForServerVersion minVersion="2.6.0">
|
||||
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)}
|
||||
</ForServerVersion>
|
||||
<ForServerVersion maxVersion="2.5.*">
|
||||
<small className="text-muted"><i>Shlink 2.6 is needed</i></small>
|
||||
</ForServerVersion>
|
||||
</CardText>
|
||||
</Card>
|
||||
<div className="col-lg-6 col-xl-3 mb-3">
|
||||
<HighlightCard title="Orphan visits" link={linkToOrphanVisits && `/server/${serverId}/orphan-visits`}>
|
||||
<ForServerVersion minVersion="2.6.0">
|
||||
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)}
|
||||
</ForServerVersion>
|
||||
<ForServerVersion maxVersion="2.5.*">
|
||||
<small className="text-muted"><i>Shlink 2.6 is needed</i></small>
|
||||
</ForServerVersion>
|
||||
</HighlightCard>
|
||||
</div>
|
||||
<div className="col-md-6 col-xl-3">
|
||||
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/list-short-urls/1`}>
|
||||
<CardTitle tag="h5" className="overview__card-title">Short URLs</CardTitle>
|
||||
<CardText tag="h2">
|
||||
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
|
||||
</CardText>
|
||||
</Card>
|
||||
<div className="col-lg-6 col-xl-3 mb-3">
|
||||
<HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}>
|
||||
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
|
||||
</HighlightCard>
|
||||
</div>
|
||||
<div className="col-md-6 col-xl-3">
|
||||
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/manage-tags`}>
|
||||
<CardTitle tag="h5" className="overview__card-title">Tags</CardTitle>
|
||||
<CardText tag="h2">{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}</CardText>
|
||||
</Card>
|
||||
<div className="col-lg-6 col-xl-3 mb-3">
|
||||
<HighlightCard title="Tags" link={`/server/${serverId}/manage-tags`}>
|
||||
{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}
|
||||
</HighlightCard>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<Card className="mb-3">
|
||||
<CardHeader>
|
||||
<span className="d-sm-none">Create a short URL</span>
|
||||
<h5 className="d-none d-sm-inline">Create a short URL</h5>
|
||||
<Link className="float-right" to={`/server/${serverId}/create-short-url`}>Advanced options »</Link>
|
||||
<Link className="float-end" to={`/server/${serverId}/create-short-url`}>Advanced options »</Link>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<CreateShortUrl basicMode />
|
||||
@@ -100,14 +96,14 @@ export const Overview = (
|
||||
<CardHeader>
|
||||
<span className="d-sm-none">Recently created URLs</span>
|
||||
<h5 className="d-none d-sm-inline">Recently created URLs</h5>
|
||||
<Link className="float-right" to={`/server/${serverId}/list-short-urls/1`}>See all »</Link>
|
||||
<Link className="float-end" to={`/server/${serverId}/list-short-urls/1`}>See all »</Link>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ShortUrlsTable
|
||||
shortUrlsList={shortUrlsList}
|
||||
selectedServer={selectedServer}
|
||||
className="mb-0"
|
||||
onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
|
||||
onTagClick={(tag) => navigate(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -17,7 +17,7 @@ const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||
if (isEmpty(serversList)) {
|
||||
return (
|
||||
<DropdownItem tag={Link} to="/server/create">
|
||||
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add a server</span>
|
||||
<FontAwesomeIcon icon={plusIcon} /> <span className="ms-1">Add a server</span>
|
||||
</DropdownItem>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||
))}
|
||||
<DropdownItem divider />
|
||||
<DropdownItem tag={Link} to="/manage-servers">
|
||||
<FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Manage servers</span>
|
||||
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Manage servers</span>
|
||||
</DropdownItem>
|
||||
</>
|
||||
);
|
||||
@@ -40,9 +40,9 @@ const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||
return (
|
||||
<UncontrolledDropdown nav inNavbar>
|
||||
<DropdownToggle nav caret>
|
||||
<FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Servers</span>
|
||||
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Servers</span>
|
||||
</DropdownToggle>
|
||||
<DropdownMenu right>{renderServers()}</DropdownMenu>
|
||||
<DropdownMenu end style={{ right: 0 }}>{renderServers()}</DropdownMenu>
|
||||
</UncontrolledDropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
padding: .75rem 2.5rem .75rem 1rem;
|
||||
}
|
||||
|
||||
.servers-list__server-item:not(:hover) {
|
||||
color: $mainColor;
|
||||
}
|
||||
|
||||
.servers-list__server-item:hover {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
40
src/servers/helpers/DuplicatedServersModal.tsx
Normal file
40
src/servers/helpers/DuplicatedServersModal.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { FC, Fragment } from 'react';
|
||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { ServerData } from '../data';
|
||||
|
||||
interface DuplicatedServersModalProps {
|
||||
duplicatedServers: ServerData[];
|
||||
isOpen: boolean;
|
||||
onDiscard: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
|
||||
{ isOpen, duplicatedServers, onDiscard, onSave },
|
||||
) => {
|
||||
const hasMultipleServers = duplicatedServers.length > 1;
|
||||
|
||||
return (
|
||||
<Modal centered isOpen={isOpen}>
|
||||
<ModalHeader>Duplicated server{hasMultipleServers && 's'}</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
|
||||
<ul>
|
||||
{duplicatedServers.map(({ url, apiKey }, index) => !hasMultipleServers ? (
|
||||
<Fragment key={index}>
|
||||
<li>URL: <b>{url}</b></li>
|
||||
<li>API key: <b>{apiKey}</b></li>
|
||||
</Fragment>
|
||||
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>)}
|
||||
</ul>
|
||||
<span>
|
||||
{hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}?
|
||||
</span>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="link" onClick={onDiscard}>{hasMultipleServers ? 'Ignore duplicated' : 'Discard'}</Button>
|
||||
<Button color="primary" onClick={onSave}>Save anyway</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
21
src/servers/helpers/HighlightCard.scss
Normal file
21
src/servers/helpers/HighlightCard.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
@import '../../utils/base';
|
||||
|
||||
.highlight-card.highlight-card {
|
||||
text-align: center;
|
||||
border-top: 3px solid var(--brand-color);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.highlight-card__link-icon {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
opacity: 0.1;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.highlight-card__title {
|
||||
text-transform: uppercase;
|
||||
color: $textPlaceholder;
|
||||
}
|
||||
21
src/servers/helpers/HighlightCard.tsx
Normal file
21
src/servers/helpers/HighlightCard.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { FC } 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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import './HighlightCard.scss';
|
||||
|
||||
export interface HighlightCardProps {
|
||||
title: string;
|
||||
link?: string | false;
|
||||
}
|
||||
|
||||
const buildExtraProps = (link?: string | false) => !link ? {} : { tag: Link, to: link };
|
||||
|
||||
export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link }) => (
|
||||
<Card 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>
|
||||
);
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useRef, RefObject, ChangeEvent, MutableRefObject, FC } from 'react';
|
||||
import { useRef, RefObject, ChangeEvent, MutableRefObject, FC, useState, useEffect } from 'react';
|
||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||
import { pipe } from 'ramda';
|
||||
import { complement, pipe } from 'ramda';
|
||||
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import ServersImporter from '../services/ServersImporter';
|
||||
import { ServerData } from '../data';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { ServersImporter } from '../services/ServersImporter';
|
||||
import { ServerData, ServersMap } from '../data';
|
||||
import { DuplicatedServersModal } from './DuplicatedServersModal';
|
||||
import './ImportServersBtn.scss';
|
||||
|
||||
type Ref<T> = RefObject<T> | MutableRefObject<T>;
|
||||
@@ -18,11 +20,16 @@ export interface ImportServersBtnProps {
|
||||
|
||||
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
||||
createServers: (servers: ServerData[]) => void;
|
||||
servers: ServersMap;
|
||||
fileRef: Ref<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const serversFiltering = (servers: ServerData[]) =>
|
||||
({ url, apiKey }: ServerData) => servers.some((server) => server.url === url && server.apiKey === apiKey);
|
||||
|
||||
const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<ImportServersBtnConnectProps> => ({
|
||||
createServers,
|
||||
servers,
|
||||
fileRef,
|
||||
children,
|
||||
onImport = () => {},
|
||||
@@ -31,15 +38,37 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
|
||||
className = '',
|
||||
}) => {
|
||||
const ref = fileRef ?? useRef<HTMLInputElement>();
|
||||
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||
const [ serversToCreate, setServersToCreate ] = useState<ServerData[] | undefined>();
|
||||
const [ duplicatedServers, setDuplicatedServers ] = useState<ServerData[]>([]);
|
||||
const [ isModalOpen,, showModal, hideModal ] = useToggle();
|
||||
const create = pipe(createServers, onImport);
|
||||
const createAllServers = pipe(() => create(serversToCreate ?? []), hideModal);
|
||||
const createNonDuplicatedServers = pipe(
|
||||
() => create((serversToCreate ?? []).filter(complement(serversFiltering(duplicatedServers)))),
|
||||
hideModal,
|
||||
);
|
||||
const onFile = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||
importServersFromFile(target.files?.[0])
|
||||
.then(pipe(createServers, onImport))
|
||||
.then(setServersToCreate)
|
||||
.then(() => {
|
||||
// Reset input after processing file
|
||||
(target as { value: string | null }).value = null;
|
||||
})
|
||||
.catch(onImportError);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serversToCreate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingServers = Object.values(servers);
|
||||
const duplicatedServers = serversToCreate.filter(serversFiltering(existingServers));
|
||||
const hasDuplicatedServers = !!duplicatedServers.length;
|
||||
|
||||
!hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(duplicatedServers);
|
||||
hasDuplicatedServers && showModal();
|
||||
}, [ serversToCreate ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}>
|
||||
@@ -49,7 +78,14 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
|
||||
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
|
||||
</UncontrolledTooltip>
|
||||
|
||||
<input type="file" accept="text/csv" className="import-servers-btn__csv-select" ref={ref} onChange={onChange} />
|
||||
<input type="file" accept="text/csv" className="import-servers-btn__csv-select" ref={ref} onChange={onFile} />
|
||||
|
||||
<DuplicatedServersModal
|
||||
isOpen={isModalOpen}
|
||||
duplicatedServers={duplicatedServers}
|
||||
onDiscard={createNonDuplicatedServers}
|
||||
onSave={createAllServers}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 './ServerError.scss';
|
||||
|
||||
interface ServerErrorProps {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.server-form .form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { FC, ReactNode, useEffect, useState } from 'react';
|
||||
import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer';
|
||||
import { InputFormGroup } from '../../utils/forms/InputFormGroup';
|
||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||
import { ServerData } from '../data';
|
||||
import { SimpleCard } from '../../utils/SimpleCard';
|
||||
import './ServerForm.scss';
|
||||
|
||||
interface ServerFormProps {
|
||||
onSubmit: (server: ServerData) => void;
|
||||
@@ -11,9 +10,6 @@ interface ServerFormProps {
|
||||
title?: ReactNode;
|
||||
}
|
||||
|
||||
const FormGroup: FC<FormGroupContainerProps> = (props) =>
|
||||
<FormGroupContainer {...props} labelClassName="create-server__label" />;
|
||||
|
||||
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
|
||||
const [ name, setName ] = useState('');
|
||||
const [ url, setUrl ] = useState('');
|
||||
@@ -29,12 +25,12 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
|
||||
return (
|
||||
<form className="server-form" onSubmit={handleSubmit}>
|
||||
<SimpleCard className="mb-3" title={title}>
|
||||
<FormGroup value={name} onChange={setName}>Name</FormGroup>
|
||||
<FormGroup type="url" value={url} onChange={setUrl}>URL</FormGroup>
|
||||
<FormGroup value={apiKey} onChange={setApiKey}>API key</FormGroup>
|
||||
<InputFormGroup value={name} onChange={setName}>Name</InputFormGroup>
|
||||
<InputFormGroup type="url" value={url} onChange={setUrl}>URL</InputFormGroup>
|
||||
<InputFormGroup value={apiKey} onChange={setApiKey}>API key</InputFormGroup>
|
||||
</SimpleCard>
|
||||
|
||||
<div className="text-right">{children}</div>
|
||||
<div className="text-end">{children}</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
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';
|
||||
|
||||
interface WithSelectedServerProps extends RouteComponentProps<{ serverId: string }> {
|
||||
interface WithSelectedServerProps {
|
||||
selectServer: (serverId: string) => void;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServerProps & T>, ServerError: FC) {
|
||||
return (props: WithSelectedServerProps & T) => {
|
||||
const { selectServer, selectedServer, match } = props;
|
||||
const params = useParams<{ serverId: string }>();
|
||||
const { selectServer, selectedServer } = props;
|
||||
|
||||
useEffect(() => {
|
||||
selectServer(match.params.serverId);
|
||||
}, [ match.params.serverId ]);
|
||||
params.serverId && selectServer(params.serverId);
|
||||
}, [ params.serverId ]);
|
||||
|
||||
if (!selectedServer) {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { identity, memoizeWith, pipe } from 'ramda';
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
||||
import { SelectedServer } from '../data';
|
||||
import { GetState } from '../../container/types';
|
||||
@@ -53,7 +52,6 @@ export const selectServer = (
|
||||
getState: GetState,
|
||||
) => {
|
||||
dispatch(resetSelectedServer());
|
||||
dispatch(resetShortUrlParams());
|
||||
|
||||
const { servers } = getState();
|
||||
const selectedServer = servers[serverId];
|
||||
|
||||
@@ -7,7 +7,7 @@ const validateServer = (server: any): server is ServerData =>
|
||||
const validateServers = (servers: any): servers is ServerData[] =>
|
||||
Array.isArray(servers) && servers.every(validateServer);
|
||||
|
||||
export default class ServersImporter {
|
||||
export class ServersImporter {
|
||||
public constructor(private readonly csvJson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
||||
|
||||
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import csvjson from 'csvjson';
|
||||
import Bottle, { Decorator } from 'bottlejs';
|
||||
import Bottle from 'bottlejs';
|
||||
import CreateServer from '../CreateServer';
|
||||
import ServersDropdown from '../ServersDropdown';
|
||||
import DeleteServerModal from '../DeleteServerModal';
|
||||
@@ -17,10 +17,10 @@ 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, withRouter: Decorator) => {
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory(
|
||||
'ManageServers',
|
||||
@@ -30,7 +30,8 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||
'useStateFlagTimeout',
|
||||
'ManageServersRow',
|
||||
);
|
||||
bottle.decorator('ManageServers', connect([ 'servers' ]));
|
||||
bottle.decorator('ManageServers', withoutSelectedServer);
|
||||
bottle.decorator('ManageServers', connect([ 'selectedServer', 'servers' ], [ 'resetSelectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('ManageServersRow', ManageServersRow, 'ManageServersRowDropdown');
|
||||
|
||||
@@ -42,19 +43,18 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||
bottle.decorator('CreateServer', connect([ 'selectedServer', 'servers' ], [ 'createServer', 'resetSelectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
|
||||
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
|
||||
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer', 'resetSelectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
|
||||
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
||||
bottle.decorator('DeleteServerModal', withRouter);
|
||||
bottle.decorator('DeleteServerModal', connect(null, [ 'deleteServer' ]));
|
||||
|
||||
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');
|
||||
|
||||
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
|
||||
bottle.decorator('ImportServersBtn', connect(null, [ 'createServers' ]));
|
||||
bottle.decorator('ImportServersBtn', connect([ 'servers' ], [ 'createServers' ]));
|
||||
|
||||
bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
|
||||
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
|
||||
|
||||
@@ -2,6 +2,8 @@ import { FormGroup, Input } from 'reactstrap';
|
||||
import classNames from 'classnames';
|
||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import { FormText } from '../utils/forms/FormText';
|
||||
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
|
||||
import { Settings } from './reducers/settings';
|
||||
|
||||
interface RealTimeUpdatesProps {
|
||||
@@ -12,22 +14,23 @@ interface RealTimeUpdatesProps {
|
||||
|
||||
const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
|
||||
|
||||
const RealTimeUpdates = (
|
||||
const RealTimeUpdatesSettings = (
|
||||
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
|
||||
) => (
|
||||
<SimpleCard title="Real-time updates" className="h-100">
|
||||
<FormGroup>
|
||||
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
|
||||
Enable or disable real-time updates.
|
||||
<small className="form-text text-muted">
|
||||
<FormText>
|
||||
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
|
||||
</small>
|
||||
</FormText>
|
||||
</ToggleSwitch>
|
||||
</FormGroup>
|
||||
<FormGroup className="mb-0">
|
||||
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
|
||||
Real-time updates frequency (in minutes):
|
||||
</label>
|
||||
<LabeledFormGroup
|
||||
noMargin
|
||||
label="Real-time updates frequency (in minutes):"
|
||||
labelClassName={classNames('form-label', { 'text-muted': !realTimeUpdates.enabled })}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -37,17 +40,17 @@ const RealTimeUpdates = (
|
||||
onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))}
|
||||
/>
|
||||
{realTimeUpdates.enabled && (
|
||||
<small className="form-text text-muted">
|
||||
<FormText>
|
||||
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
|
||||
<span>
|
||||
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
|
||||
</span>
|
||||
)}
|
||||
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
|
||||
</small>
|
||||
</FormText>
|
||||
)}
|
||||
</FormGroup>
|
||||
</LabeledFormGroup>
|
||||
</SimpleCard>
|
||||
);
|
||||
|
||||
export default RealTimeUpdates;
|
||||
export default RealTimeUpdatesSettings;
|
||||
@@ -1,29 +1,35 @@
|
||||
import { FC, ReactNode } from 'react';
|
||||
import { Row } from 'reactstrap';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
import { Navigate, Routes, Route } from 'react-router-dom';
|
||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||
import { NavPillItem, NavPills } from '../utils/NavPills';
|
||||
|
||||
const SettingsSections: FC<{ items: ReactNode[][] }> = ({ items }) => (
|
||||
const SettingsSections: FC<{ items: ReactNode[] }> = ({ items }) => (
|
||||
<>
|
||||
{items.map((child, index) => (
|
||||
<Row key={index}>
|
||||
{child.map((subChild, subIndex) => (
|
||||
<div key={subIndex} className="col-lg-6 mb-3">
|
||||
{subChild}
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
))}
|
||||
{items.map((child, index) => <div key={index} className="mb-3">{child}</div>)}
|
||||
</>
|
||||
);
|
||||
|
||||
const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC, Visits: FC) => () => (
|
||||
const Settings = (
|
||||
RealTimeUpdates: FC,
|
||||
ShortUrlCreation: FC,
|
||||
ShortUrlsList: FC,
|
||||
UserInterface: FC,
|
||||
Visits: FC,
|
||||
Tags: FC,
|
||||
) => () => (
|
||||
<NoMenuLayout>
|
||||
<SettingsSections
|
||||
items={[
|
||||
[ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key
|
||||
[ <ShortUrlCreation />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
|
||||
]}
|
||||
/>
|
||||
<NavPills className="mb-3">
|
||||
<NavPillItem to="general">General</NavPillItem>
|
||||
<NavPillItem to="short-urls">Short URLs</NavPillItem>
|
||||
<NavPillItem to="other-items">Other items</NavPillItem>
|
||||
</NavPills>
|
||||
|
||||
<Routes>
|
||||
<Route path="general" element={<SettingsSections items={[ <UserInterface key="one" />, <RealTimeUpdates key="two" /> ]} />} />
|
||||
<Route path="short-urls" element={<SettingsSections items={[ <ShortUrlCreation key="one" />, <ShortUrlsList key="two" /> ]} />} />
|
||||
<Route path="other-items" element={<SettingsSections items={[ <Tags key="one" />, <Visits key="two" /> ]} />} />
|
||||
<Route path="*" element={<Navigate replace to="general" />} />
|
||||
</Routes>
|
||||
</NoMenuLayout>
|
||||
);
|
||||
|
||||
|
||||
@@ -3,11 +3,13 @@ import { DropdownItem, FormGroup } from 'reactstrap';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||
import { DropdownBtn } from '../utils/DropdownBtn';
|
||||
import { Settings, ShortUrlCreationSettings, TagFilteringMode } from './reducers/settings';
|
||||
import { FormText } from '../utils/forms/FormText';
|
||||
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
|
||||
import { Settings, ShortUrlCreationSettings as ShortUrlsSettings, TagFilteringMode } from './reducers/settings';
|
||||
|
||||
interface ShortUrlCreationProps {
|
||||
settings: Settings;
|
||||
setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void;
|
||||
setShortUrlCreationSettings: (settings: ShortUrlsSettings) => void;
|
||||
}
|
||||
|
||||
const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
|
||||
@@ -17,8 +19,8 @@ const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): R
|
||||
? <>The list of suggested tags will contain those <b>including</b> provided input.</>
|
||||
: <>The list of suggested tags will contain those <b>starting with</b> provided input.</>;
|
||||
|
||||
export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
|
||||
const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false };
|
||||
export const ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
|
||||
const shortUrlCreation: ShortUrlsSettings = settings.shortUrlCreation ?? { validateUrls: false };
|
||||
const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings(
|
||||
{ ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode },
|
||||
);
|
||||
@@ -31,10 +33,10 @@ export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShort
|
||||
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
|
||||
>
|
||||
Request validation on long URLs when creating new short URLs.
|
||||
<small className="form-text text-muted">
|
||||
<FormText>
|
||||
The initial state of the <b>Validate URL</b> checkbox will
|
||||
be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>.
|
||||
</small>
|
||||
</FormText>
|
||||
</ToggleSwitch>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
@@ -43,14 +45,13 @@ export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShort
|
||||
onChange={(forwardQuery) => setShortUrlCreationSettings({ ...shortUrlCreation, forwardQuery })}
|
||||
>
|
||||
Make all new short URLs forward their query params to the long URL.
|
||||
<small className="form-text text-muted">
|
||||
<FormText>
|
||||
The initial state of the <b>Forward query params on redirect</b> checkbox will
|
||||
be <b>{shortUrlCreation.forwardQuery ?? true ? 'checked' : 'unchecked'}</b>.
|
||||
</small>
|
||||
</FormText>
|
||||
</ToggleSwitch>
|
||||
</FormGroup>
|
||||
<FormGroup className="mb-0">
|
||||
<label>Tag suggestions search mode:</label>
|
||||
<LabeledFormGroup noMargin label="Tag suggestions search mode:">
|
||||
<DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}>
|
||||
<DropdownItem
|
||||
active={!shortUrlCreation.tagFilteringMode || shortUrlCreation.tagFilteringMode === 'startsWith'}
|
||||
@@ -65,10 +66,8 @@ export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShort
|
||||
{tagFilteringModeText('includes')}
|
||||
</DropdownItem>
|
||||
</DropdownBtn>
|
||||
<small className="form-text text-muted">
|
||||
{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}
|
||||
</small>
|
||||
</FormGroup>
|
||||
<FormText>{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}</FormText>
|
||||
</LabeledFormGroup>
|
||||
</SimpleCard>
|
||||
);
|
||||
};
|
||||
25
src/settings/ShortUrlsListSettings.tsx
Normal file
25
src/settings/ShortUrlsListSettings.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { FC } from 'react';
|
||||
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||
import { SHORT_URLS_ORDERABLE_FIELDS } from '../short-urls/data';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
|
||||
import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings as ShortUrlsSettings } from './reducers/settings';
|
||||
|
||||
interface ShortUrlsListSettingsProps {
|
||||
settings: Settings;
|
||||
setShortUrlsListSettings: (settings: ShortUrlsSettings) => void;
|
||||
}
|
||||
|
||||
export const ShortUrlsListSettings: FC<ShortUrlsListSettingsProps> = (
|
||||
{ settings: { shortUrlsList }, setShortUrlsListSettings },
|
||||
) => (
|
||||
<SimpleCard title="Short URLs list" className="h-100">
|
||||
<LabeledFormGroup noMargin label="Default ordering for short URLs list:">
|
||||
<OrderingDropdown
|
||||
items={SHORT_URLS_ORDERABLE_FIELDS}
|
||||
order={shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING}
|
||||
onChange={(field, dir) => setShortUrlsListSettings({ defaultOrdering: { field, dir } })}
|
||||
/>
|
||||
</LabeledFormGroup>
|
||||
</SimpleCard>
|
||||
);
|
||||
34
src/settings/TagsSettings.tsx
Normal file
34
src/settings/TagsSettings.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { FC } from 'react';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
|
||||
import { capitalize } from '../utils/utils';
|
||||
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||
import { TAGS_ORDERABLE_FIELDS } from '../tags/data/TagsListChildrenProps';
|
||||
import { FormText } from '../utils/forms/FormText';
|
||||
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
|
||||
import { Settings, TagsSettings as TagsSettingsOptions } from './reducers/settings';
|
||||
|
||||
interface TagsProps {
|
||||
settings: Settings;
|
||||
setTagsSettings: (settings: TagsSettingsOptions) => void;
|
||||
}
|
||||
|
||||
export const TagsSettings: FC<TagsProps> = ({ settings: { tags }, setTagsSettings }) => (
|
||||
<SimpleCard title="Tags" className="h-100">
|
||||
<LabeledFormGroup label="Default display mode when managing tags:">
|
||||
<TagsModeDropdown
|
||||
mode={tags?.defaultMode ?? 'cards'}
|
||||
renderTitle={(tagsMode) => capitalize(tagsMode)}
|
||||
onChange={(defaultMode) => setTagsSettings({ ...tags, defaultMode })}
|
||||
/>
|
||||
<FormText>Tags will be displayed as <b>{tags?.defaultMode ?? 'cards'}</b>.</FormText>
|
||||
</LabeledFormGroup>
|
||||
<LabeledFormGroup noMargin label="Default ordering for tags list:">
|
||||
<OrderingDropdown
|
||||
items={TAGS_ORDERABLE_FIELDS}
|
||||
order={tags?.defaultOrdering ?? {}}
|
||||
onChange={(field, dir) => setTagsSettings({ ...tags, defaultOrdering: { field, dir } })}
|
||||
/>
|
||||
</LabeledFormGroup>
|
||||
</SimpleCard>
|
||||
);
|
||||
@@ -1,44 +0,0 @@
|
||||
import { FC } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FormGroup } from 'reactstrap';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||
import { changeThemeInMarkup, Theme } from '../utils/theme';
|
||||
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
|
||||
import { capitalize } from '../utils/utils';
|
||||
import { Settings, UiSettings } from './reducers/settings';
|
||||
import './UserInterface.scss';
|
||||
|
||||
interface UserInterfaceProps {
|
||||
settings: Settings;
|
||||
setUiSettings: (settings: UiSettings) => void;
|
||||
}
|
||||
|
||||
export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
|
||||
<SimpleCard title="User interface" className="h-100">
|
||||
<FormGroup>
|
||||
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
||||
<ToggleSwitch
|
||||
checked={ui?.theme === 'dark'}
|
||||
onChange={(useDarkTheme) => {
|
||||
const theme: Theme = useDarkTheme ? 'dark' : 'light';
|
||||
|
||||
setUiSettings({ ...ui, theme });
|
||||
changeThemeInMarkup(theme);
|
||||
}}
|
||||
>
|
||||
Use dark theme.
|
||||
</ToggleSwitch>
|
||||
</FormGroup>
|
||||
<FormGroup className="mb-0">
|
||||
<label>Default display mode when managing tags:</label>
|
||||
<TagsModeDropdown
|
||||
mode={ui?.tagsMode ?? 'cards'}
|
||||
renderTitle={(tagsMode) => capitalize(tagsMode)}
|
||||
onChange={(tagsMode) => setUiSettings({ ...ui ?? { theme: 'light' }, tagsMode })}
|
||||
/>
|
||||
<small className="form-text text-muted">Tags will be displayed as <b>{ui?.tagsMode ?? 'cards'}</b>.</small>
|
||||
</FormGroup>
|
||||
</SimpleCard>
|
||||
);
|
||||
30
src/settings/UserInterfaceSettings.tsx
Normal file
30
src/settings/UserInterfaceSettings.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { FC } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||
import { changeThemeInMarkup, Theme } from '../utils/theme';
|
||||
import { Settings, UiSettings } from './reducers/settings';
|
||||
import './UserInterfaceSettings.scss';
|
||||
|
||||
interface UserInterfaceProps {
|
||||
settings: Settings;
|
||||
setUiSettings: (settings: UiSettings) => void;
|
||||
}
|
||||
|
||||
export const UserInterfaceSettings: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
|
||||
<SimpleCard title="User interface" className="h-100">
|
||||
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
||||
<ToggleSwitch
|
||||
checked={ui?.theme === 'dark'}
|
||||
onChange={(useDarkTheme) => {
|
||||
const theme: Theme = useDarkTheme ? 'dark' : 'light';
|
||||
|
||||
setUiSettings({ ...ui, theme });
|
||||
changeThemeInMarkup(theme);
|
||||
}}
|
||||
>
|
||||
Use dark theme.
|
||||
</ToggleSwitch>
|
||||
</SimpleCard>
|
||||
);
|
||||
@@ -1,23 +1,22 @@
|
||||
import { FormGroup } from 'reactstrap';
|
||||
import { FC } from 'react';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
|
||||
import { Settings, VisitsSettings } from './reducers/settings';
|
||||
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
|
||||
import { Settings, VisitsSettings as VisitsSettingsConfig } from './reducers/settings';
|
||||
|
||||
interface VisitsProps {
|
||||
settings: Settings;
|
||||
setVisitsSettings: (settings: VisitsSettings) => void;
|
||||
setVisitsSettings: (settings: VisitsSettingsConfig) => void;
|
||||
}
|
||||
|
||||
export const Visits: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
|
||||
export const VisitsSettings: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
|
||||
<SimpleCard title="Visits" className="h-100">
|
||||
<FormGroup className="mb-0">
|
||||
<label>Default interval to load on visits sections:</label>
|
||||
<LabeledFormGroup noMargin label="Default interval to load on visits sections:">
|
||||
<DateIntervalSelector
|
||||
allText="All visits"
|
||||
active={settings.visits?.defaultInterval ?? 'last30Days'}
|
||||
onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
|
||||
/>
|
||||
</FormGroup>
|
||||
</LabeledFormGroup>
|
||||
</SimpleCard>
|
||||
);
|
||||
21
src/settings/helpers/index.ts
Normal file
21
src/settings/helpers/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ShlinkState } from '../../container/types';
|
||||
|
||||
export const migrateDeprecatedSettings = (state: Partial<ShlinkState>): Partial<ShlinkState> => {
|
||||
if (!state.settings) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// The "last180Days" interval had a typo, with a lowercase d
|
||||
if ((state.settings.visits?.defaultInterval as any) === 'last180days') {
|
||||
state.settings.visits && (state.settings.visits.defaultInterval = 'last180Days');
|
||||
}
|
||||
|
||||
// The "tags display mode" option has been moved from "ui" to "tags"
|
||||
state.settings.tags = {
|
||||
...state.settings.tags,
|
||||
defaultMode: state.settings.tags?.defaultMode ?? (state.settings.ui as any)?.tagsMode,
|
||||
};
|
||||
state.settings.ui && delete (state.settings.ui as any).tagsMode;
|
||||
|
||||
return state;
|
||||
};
|
||||
@@ -4,9 +4,16 @@ import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { RecursivePartial } from '../../utils/utils';
|
||||
import { Theme } from '../../utils/theme';
|
||||
import { DateInterval } from '../../utils/dates/types';
|
||||
import { TagsOrder } from '../../tags/data/TagsListChildrenProps';
|
||||
import { ShortUrlsOrder } from '../../short-urls/data';
|
||||
|
||||
export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS';
|
||||
|
||||
export const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = {
|
||||
field: 'dateCreated',
|
||||
dir: 'DESC',
|
||||
};
|
||||
|
||||
/**
|
||||
* Important! When adding new props in the main Settings interface or any of the nested props, they have to be set as
|
||||
* optional, as old instances of the app will load partial objects from local storage until it is saved again.
|
||||
@@ -29,18 +36,28 @@ export type TagsMode = 'cards' | 'list';
|
||||
|
||||
export interface UiSettings {
|
||||
theme: Theme;
|
||||
tagsMode?: TagsMode;
|
||||
}
|
||||
|
||||
export interface VisitsSettings {
|
||||
defaultInterval: DateInterval;
|
||||
}
|
||||
|
||||
export interface TagsSettings {
|
||||
defaultOrdering?: TagsOrder;
|
||||
defaultMode?: TagsMode;
|
||||
}
|
||||
|
||||
export interface ShortUrlsListSettings {
|
||||
defaultOrdering?: ShortUrlsOrder;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
realTimeUpdates: RealTimeUpdatesSettings;
|
||||
shortUrlCreation?: ShortUrlCreationSettings;
|
||||
shortUrlsList?: ShortUrlsListSettings;
|
||||
ui?: UiSettings;
|
||||
visits?: VisitsSettings;
|
||||
tags?: TagsSettings;
|
||||
}
|
||||
|
||||
const initialState: Settings = {
|
||||
@@ -56,6 +73,9 @@ const initialState: Settings = {
|
||||
visits: {
|
||||
defaultInterval: 'last30Days',
|
||||
},
|
||||
shortUrlsList: {
|
||||
defaultOrdering: DEFAULT_SHORT_URLS_ORDERING,
|
||||
},
|
||||
};
|
||||
|
||||
type SettingsAction = Action & Settings;
|
||||
@@ -81,6 +101,11 @@ export const setShortUrlCreationSettings = (settings: ShortUrlCreationSettings):
|
||||
shortUrlCreation: settings,
|
||||
});
|
||||
|
||||
export const setShortUrlsListSettings = (settings: ShortUrlsListSettings): PartialSettingsAction => ({
|
||||
type: SET_SETTINGS,
|
||||
shortUrlsList: settings,
|
||||
});
|
||||
|
||||
export const setUiSettings = (settings: UiSettings): PartialSettingsAction => ({
|
||||
type: SET_SETTINGS,
|
||||
ui: settings,
|
||||
@@ -90,3 +115,8 @@ export const setVisitsSettings = (settings: VisitsSettings): PartialSettingsActi
|
||||
type: SET_SETTINGS,
|
||||
visits: settings,
|
||||
});
|
||||
|
||||
export const setTagsSettings = (settings: TagsSettings): PartialSettingsAction => ({
|
||||
type: SET_SETTINGS,
|
||||
tags: settings,
|
||||
});
|
||||
|
||||
@@ -1,46 +1,67 @@
|
||||
import Bottle from 'bottlejs';
|
||||
import RealTimeUpdates from '../RealTimeUpdates';
|
||||
import RealTimeUpdatesSettings from '../RealTimeUpdatesSettings';
|
||||
import Settings from '../Settings';
|
||||
import {
|
||||
setRealTimeUpdatesInterval,
|
||||
setShortUrlCreationSettings,
|
||||
setShortUrlsListSettings,
|
||||
setTagsSettings,
|
||||
setUiSettings,
|
||||
setVisitsSettings,
|
||||
toggleRealTimeUpdates,
|
||||
} from '../reducers/settings';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||
import { ShortUrlCreation } from '../ShortUrlCreation';
|
||||
import { UserInterface } from '../UserInterface';
|
||||
import { Visits } from '../Visits';
|
||||
import { ShortUrlCreationSettings } from '../ShortUrlCreationSettings';
|
||||
import { UserInterfaceSettings } from '../UserInterfaceSettings';
|
||||
import { VisitsSettings } from '../VisitsSettings';
|
||||
import { TagsSettings } from '../TagsSettings';
|
||||
import { ShortUrlsListSettings } from '../ShortUrlsListSettings';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation', 'UserInterface', 'Visits');
|
||||
bottle.serviceFactory(
|
||||
'Settings',
|
||||
Settings,
|
||||
'RealTimeUpdatesSettings',
|
||||
'ShortUrlCreationSettings',
|
||||
'ShortUrlsListSettings',
|
||||
'UserInterfaceSettings',
|
||||
'VisitsSettings',
|
||||
'TagsSettings',
|
||||
);
|
||||
bottle.decorator('Settings', withoutSelectedServer);
|
||||
bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates);
|
||||
bottle.serviceFactory('RealTimeUpdatesSettings', () => RealTimeUpdatesSettings);
|
||||
bottle.decorator(
|
||||
'RealTimeUpdates',
|
||||
'RealTimeUpdatesSettings',
|
||||
connect([ 'settings' ], [ 'toggleRealTimeUpdates', 'setRealTimeUpdatesInterval' ]),
|
||||
);
|
||||
|
||||
bottle.serviceFactory('ShortUrlCreation', () => ShortUrlCreation);
|
||||
bottle.decorator('ShortUrlCreation', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ]));
|
||||
bottle.serviceFactory('ShortUrlCreationSettings', () => ShortUrlCreationSettings);
|
||||
bottle.decorator('ShortUrlCreationSettings', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ]));
|
||||
|
||||
bottle.serviceFactory('UserInterface', () => UserInterface);
|
||||
bottle.decorator('UserInterface', connect([ 'settings' ], [ 'setUiSettings' ]));
|
||||
bottle.serviceFactory('UserInterfaceSettings', () => UserInterfaceSettings);
|
||||
bottle.decorator('UserInterfaceSettings', connect([ 'settings' ], [ 'setUiSettings' ]));
|
||||
|
||||
bottle.serviceFactory('Visits', () => Visits);
|
||||
bottle.decorator('Visits', connect([ 'settings' ], [ 'setVisitsSettings' ]));
|
||||
bottle.serviceFactory('VisitsSettings', () => VisitsSettings);
|
||||
bottle.decorator('VisitsSettings', connect([ 'settings' ], [ 'setVisitsSettings' ]));
|
||||
|
||||
bottle.serviceFactory('TagsSettings', () => TagsSettings);
|
||||
bottle.decorator('TagsSettings', connect([ 'settings' ], [ 'setTagsSettings' ]));
|
||||
|
||||
bottle.serviceFactory('ShortUrlsListSettings', () => ShortUrlsListSettings);
|
||||
bottle.decorator('ShortUrlsListSettings', connect([ 'settings' ], [ 'setShortUrlsListSettings' ]));
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
|
||||
bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval);
|
||||
bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings);
|
||||
bottle.serviceFactory('setShortUrlsListSettings', () => setShortUrlsListSettings);
|
||||
bottle.serviceFactory('setUiSettings', () => setUiSettings);
|
||||
bottle.serviceFactory('setVisitsSettings', () => setVisitsSettings);
|
||||
bottle.serviceFactory('setTagsSettings', () => setTagsSettings);
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { FC, useEffect, useMemo } from 'react';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
import { Button, Card } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings';
|
||||
import { OptionalString } from '../utils/utils';
|
||||
@@ -11,13 +11,13 @@ import { parseQuery } from '../utils/helpers/query';
|
||||
import Message from '../utils/Message';
|
||||
import { Result } from '../utils/Result';
|
||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { useGoBack, useToggle } from '../utils/helpers/hooks';
|
||||
import { ShortUrlFormProps } from './ShortUrlForm';
|
||||
import { ShortUrlDetail } from './reducers/shortUrlDetail';
|
||||
import { EditShortUrlData, ShortUrl, ShortUrlData } from './data';
|
||||
import { ShortUrlEdition } from './reducers/shortUrlEdition';
|
||||
|
||||
interface EditShortUrlConnectProps extends RouteComponentProps<{ shortCode: string }> {
|
||||
interface EditShortUrlConnectProps {
|
||||
settings: Settings;
|
||||
selectedServer: SelectedServer;
|
||||
shortUrlDetail: ShortUrlDetail;
|
||||
@@ -48,9 +48,6 @@ const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSetting
|
||||
};
|
||||
|
||||
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
||||
history: { goBack },
|
||||
match: { params },
|
||||
location: { search },
|
||||
settings: { shortUrlCreation: shortUrlCreationSettings },
|
||||
selectedServer,
|
||||
shortUrlDetail,
|
||||
@@ -58,6 +55,9 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
||||
shortUrlEdition,
|
||||
editShortUrl,
|
||||
}: EditShortUrlConnectProps) => {
|
||||
const { search } = useLocation();
|
||||
const params = useParams<{ shortCode: string }>();
|
||||
const goBack = useGoBack();
|
||||
const { loading, error, errorData, shortUrl } = shortUrlDetail;
|
||||
const { saving, error: savingError, errorData: savingErrorData } = shortUrlEdition;
|
||||
const { domain } = parseQuery<{ domain?: string }>(search);
|
||||
@@ -68,7 +68,7 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
||||
const [ savingSucceeded,, isSuccessful, isNotSuccessful ] = useToggle();
|
||||
|
||||
useEffect(() => {
|
||||
getShortUrlDetail(params.shortCode, domain);
|
||||
params.shortCode && getShortUrlDetail(params.shortCode, domain);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
@@ -88,7 +88,7 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
||||
<header className="mb-3">
|
||||
<Card body>
|
||||
<h2 className="d-sm-flex justify-content-between align-items-center mb-0">
|
||||
<Button color="link" size="lg" className="p-0 mr-3" onClick={goBack}>
|
||||
<Button color="link" size="lg" className="p-0 me-3" onClick={goBack}>
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
</Button>
|
||||
<span className="text-center">
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.search-bar__tags-icon {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { isEmpty, pipe } from 'ramda';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { RouteChildrenProps } from 'react-router-dom';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import Tag from '../tags/helpers/Tag';
|
||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||
import { formatIsoDate } from '../utils/helpers/date';
|
||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||
import { DateRange } from '../utils/dates/types';
|
||||
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
|
||||
import './SearchBar.scss';
|
||||
|
||||
export type SearchBarProps = RouteChildrenProps<ShortUrlListRouteParams>;
|
||||
|
||||
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
|
||||
|
||||
const SearchBar = (colorGenerator: ColorGenerator) => (props: SearchBarProps) => {
|
||||
const [{ search, tags, startDate, endDate }, toFirstPage ] = useShortUrlsQuery(props);
|
||||
const selectedTags = tags?.split(',') ?? [];
|
||||
const setDates = pipe(
|
||||
({ startDate, endDate }: DateRange) => ({
|
||||
startDate: formatIsoDate(startDate) ?? undefined,
|
||||
endDate: formatIsoDate(endDate) ?? undefined,
|
||||
}),
|
||||
toFirstPage,
|
||||
);
|
||||
const setSearch = pipe(
|
||||
(searchTerm: string) => isEmpty(searchTerm) ? undefined : searchTerm,
|
||||
(search) => toFirstPage({ search }),
|
||||
);
|
||||
const removeTag = pipe(
|
||||
(tag: string) => selectedTags.filter((selectedTag) => selectedTag !== tag),
|
||||
(tagsList) => tagsList.length === 0 ? undefined : tagsList.join(','),
|
||||
(tags) => toFirstPage({ tags }),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="search-bar-container">
|
||||
<SearchField initialValue={search} onChange={setSearch} />
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="row">
|
||||
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
|
||||
<DateRangeSelector
|
||||
defaultText="All short URLs"
|
||||
initialDateRange={{
|
||||
startDate: dateOrNull(startDate),
|
||||
endDate: dateOrNull(endDate),
|
||||
}}
|
||||
onDatesChange={setDates}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTags.length > 0 && (
|
||||
<h4 className="search-bar__selected-tag mt-3">
|
||||
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
||||
|
||||
{selectedTags.map((tag) =>
|
||||
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
|
||||
</h4>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
@@ -1,6 +1,5 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.short-url-form .card-body > .form-group:last-child,
|
||||
.short-url-form p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { InputType } from 'reactstrap/lib/Input';
|
||||
import { InputType } from 'reactstrap/types/lib/Input';
|
||||
import { Button, FormGroup, Input, Row } from 'reactstrap';
|
||||
import { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
|
||||
import classNames from 'classnames';
|
||||
@@ -41,6 +41,7 @@ export const ShortUrlForm = (
|
||||
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => {
|
||||
const [ shortUrlData, setShortUrlData ] = useState(initialState);
|
||||
const isEdit = mode === 'edit';
|
||||
const isBasicMode = mode === 'create-basic';
|
||||
const hadTitleOriginally = hasValue(initialState.title);
|
||||
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
|
||||
const reset = () => setShortUrlData(initialState);
|
||||
@@ -66,8 +67,14 @@ export const ShortUrlForm = (
|
||||
setShortUrlData(initialState);
|
||||
}, [ initialState ]);
|
||||
|
||||
const renderOptionalInput = (id: NonDateFields, placeholder: string, type: InputType = 'text', props = {}) => (
|
||||
<FormGroup>
|
||||
const renderOptionalInput = (
|
||||
id: NonDateFields,
|
||||
placeholder: string,
|
||||
type: InputType = 'text',
|
||||
props = {},
|
||||
fromGroupProps = {},
|
||||
) => (
|
||||
<FormGroup {...fromGroupProps}>
|
||||
<Input
|
||||
id={id}
|
||||
type={type}
|
||||
@@ -79,15 +86,13 @@ export const ShortUrlForm = (
|
||||
</FormGroup>
|
||||
);
|
||||
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
|
||||
<div className="form-group">
|
||||
<DateInput
|
||||
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
|
||||
placeholderText={placeholder}
|
||||
isClearable
|
||||
onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
<DateInput
|
||||
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
|
||||
placeholderText={placeholder}
|
||||
isClearable
|
||||
onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
const basicComponents = (
|
||||
<>
|
||||
@@ -101,10 +106,12 @@ export const ShortUrlForm = (
|
||||
onChange={(e) => setShortUrlData({ ...shortUrlData, longUrl: e.target.value })}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
|
||||
</FormGroup>
|
||||
<Row>
|
||||
{isBasicMode && renderOptionalInput('customSlug', 'Custom slug', 'text', { bsSize: 'lg' }, { className: 'col-lg-6' })}
|
||||
<div className={isBasicMode ? 'col-lg-6' : 'col-12 mb-0'}>
|
||||
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
|
||||
</div>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -118,8 +125,8 @@ export const ShortUrlForm = (
|
||||
|
||||
return (
|
||||
<form className="short-url-form" onSubmit={submit}>
|
||||
{mode === 'create-basic' && basicComponents}
|
||||
{mode !== 'create-basic' && (
|
||||
{isBasicMode && basicComponents}
|
||||
{!isBasicMode && (
|
||||
<>
|
||||
<SimpleCard title="Basic options" className="mb-3">
|
||||
{basicComponents}
|
||||
@@ -145,12 +152,10 @@ export const ShortUrlForm = (
|
||||
})}
|
||||
</div>
|
||||
</Row>
|
||||
<FormGroup>
|
||||
<DomainSelector
|
||||
value={shortUrlData.domain}
|
||||
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
|
||||
/>
|
||||
</FormGroup>
|
||||
<DomainSelector
|
||||
value={shortUrlData.domain}
|
||||
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SimpleCard>
|
||||
@@ -160,7 +165,9 @@ export const ShortUrlForm = (
|
||||
<div className={limitAccessCardClasses}>
|
||||
<SimpleCard title="Limit access to the short URL">
|
||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? toDate(shortUrlData.validUntil) : undefined })}
|
||||
<div className="mb-3">
|
||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? toDate(shortUrlData.validUntil) : undefined })}
|
||||
</div>
|
||||
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? toDate(shortUrlData.validSince) : undefined })}
|
||||
</SimpleCard>
|
||||
</div>
|
||||
@@ -180,7 +187,7 @@ export const ShortUrlForm = (
|
||||
<p>
|
||||
<Checkbox
|
||||
inline
|
||||
className="mr-2"
|
||||
className="me-2"
|
||||
checked={shortUrlData.findIfExists}
|
||||
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
|
||||
>
|
||||
|
||||
3
src/short-urls/ShortUrlsFilteringBar.scss
Normal file
3
src/short-urls/ShortUrlsFilteringBar.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.short-urls-filtering-bar__tags-icon {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
105
src/short-urls/ShortUrlsFilteringBar.tsx
Normal file
105
src/short-urls/ShortUrlsFilteringBar.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { FC } from 'react';
|
||||
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { isEmpty, pipe } from 'ramda';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { Row } from 'reactstrap';
|
||||
import classNames from 'classnames';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import Tag from '../tags/helpers/Tag';
|
||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||
import { formatIsoDate } from '../utils/helpers/date';
|
||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||
import { DateRange } from '../utils/dates/types';
|
||||
import { supportsAllTagsFiltering } from '../utils/helpers/features';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
import { TooltipToggleSwitch } from '../utils/TooltipToggleSwitch';
|
||||
import { OrderDir } from '../utils/helpers/ordering';
|
||||
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||
import { useShortUrlsQuery } from './helpers/hooks';
|
||||
import { SHORT_URLS_ORDERABLE_FIELDS, ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
|
||||
import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
|
||||
import './ShortUrlsFilteringBar.scss';
|
||||
|
||||
export interface ShortUrlsFilteringProps {
|
||||
selectedServer: SelectedServer;
|
||||
order: ShortUrlsOrder;
|
||||
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
|
||||
className?: string;
|
||||
shortUrlsAmount?: number;
|
||||
}
|
||||
|
||||
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
|
||||
|
||||
const ShortUrlsFilteringBar = (
|
||||
colorGenerator: ColorGenerator,
|
||||
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
|
||||
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => {
|
||||
const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage ] = useShortUrlsQuery();
|
||||
const setDates = pipe(
|
||||
({ startDate, endDate }: DateRange) => ({
|
||||
startDate: formatIsoDate(startDate) ?? undefined,
|
||||
endDate: formatIsoDate(endDate) ?? undefined,
|
||||
}),
|
||||
toFirstPage,
|
||||
);
|
||||
const setSearch = pipe(
|
||||
(searchTerm: string) => isEmpty(searchTerm) ? undefined : searchTerm,
|
||||
(search) => toFirstPage({ search }),
|
||||
);
|
||||
const removeTag = pipe(
|
||||
(tag: string) => tags.filter((selectedTag) => selectedTag !== tag),
|
||||
(updateTags) => toFirstPage({ tags: updateTags }),
|
||||
);
|
||||
const canChangeTagsMode = supportsAllTagsFiltering(selectedServer);
|
||||
const toggleTagsMode = pipe(
|
||||
() => tagsMode === 'any' ? 'all' : 'any',
|
||||
(tagsMode) => toFirstPage({ tagsMode }),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames('short-urls-filtering-bar-container', className)}>
|
||||
<SearchField initialValue={search} onChange={setSearch} />
|
||||
|
||||
<Row className="flex-column-reverse flex-lg-row">
|
||||
<div className="col-lg-4 col-xl-6 mt-3">
|
||||
<ExportShortUrlsBtn amount={shortUrlsAmount} />
|
||||
</div>
|
||||
<div className="col-12 d-block d-lg-none mt-3">
|
||||
<OrderingDropdown items={SHORT_URLS_ORDERABLE_FIELDS} order={order} onChange={handleOrderBy} />
|
||||
</div>
|
||||
<div className="col-lg-8 col-xl-6 mt-3">
|
||||
<DateRangeSelector
|
||||
defaultText="All short URLs"
|
||||
initialDateRange={{
|
||||
startDate: dateOrNull(startDate),
|
||||
endDate: dateOrNull(endDate),
|
||||
}}
|
||||
onDatesChange={setDates}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<h4 className="mt-3">
|
||||
{canChangeTagsMode && tags.length > 1 && (
|
||||
<div className="float-end ms-2 mt-1">
|
||||
<TooltipToggleSwitch
|
||||
checked={tagsMode === 'all'}
|
||||
tooltip={{ placement: 'left' }}
|
||||
onChange={toggleTagsMode}
|
||||
>
|
||||
{tagsMode === 'all' ? 'Short URLs including all tags.' : 'Short URLs including any tag.'}
|
||||
</TooltipToggleSwitch>
|
||||
</div>
|
||||
)}
|
||||
<FontAwesomeIcon icon={tagsIcon} className="short-urls-filtering-bar__tags-icon me-1" />
|
||||
{tags.map((tag) =>
|
||||
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
|
||||
</h4>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortUrlsFilteringBar;
|
||||
@@ -1,78 +1,75 @@
|
||||
import { head, keys, pipe, values } from 'ramda';
|
||||
import { FC, useEffect, useMemo, useState } from 'react';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
import { pipe } from 'ramda';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { Card } from 'reactstrap';
|
||||
import SortingDropdown from '../utils/SortingDropdown';
|
||||
import { determineOrderDir, Order, OrderDir } from '../utils/helpers/ordering';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { determineOrderDir, OrderDir } from '../utils/helpers/ordering';
|
||||
import { getServerId, SelectedServer } from '../servers/data';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||
import { ShlinkShortUrlsListParams } from '../api/types';
|
||||
import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings';
|
||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
|
||||
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
||||
import Paginator from './Paginator';
|
||||
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
|
||||
import { useShortUrlsQuery } from './helpers/hooks';
|
||||
import { ShortUrlsOrderableFields } from './data';
|
||||
import { ShortUrlsFilteringProps } from './ShortUrlsFilteringBar';
|
||||
|
||||
interface ShortUrlsListProps extends RouteComponentProps<ShortUrlListRouteParams> {
|
||||
interface ShortUrlsListProps {
|
||||
selectedServer: SelectedServer;
|
||||
shortUrlsList: ShortUrlsListState;
|
||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
||||
shortUrlsListParams: ShortUrlsListParams;
|
||||
resetShortUrlParams: () => void;
|
||||
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
type ShortUrlsOrder = Order<OrderableFields>;
|
||||
|
||||
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, SearchBar: FC) => boundToMercureHub(({
|
||||
listShortUrls,
|
||||
resetShortUrlParams,
|
||||
shortUrlsListParams,
|
||||
match,
|
||||
location,
|
||||
history,
|
||||
shortUrlsList,
|
||||
selectedServer,
|
||||
}: ShortUrlsListProps) => {
|
||||
const ShortUrlsList = (
|
||||
ShortUrlsTable: FC<ShortUrlsTableProps>,
|
||||
ShortUrlsFilteringBar: FC<ShortUrlsFilteringProps>,
|
||||
) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => {
|
||||
const serverId = getServerId(selectedServer);
|
||||
const { orderBy } = shortUrlsListParams;
|
||||
const [ order, setOrder ] = useState<ShortUrlsOrder>({
|
||||
field: orderBy && (head(keys(orderBy)) as OrderableFields),
|
||||
dir: orderBy && head(values(orderBy)),
|
||||
});
|
||||
const [{ tags, search, startDate, endDate }, toFirstPage ] = useShortUrlsQuery({ history, match, location });
|
||||
const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]);
|
||||
const { page } = useParams();
|
||||
const location = useLocation();
|
||||
const [{ tags, search, startDate, endDate, orderBy, tagsMode }, toFirstPage ] = useShortUrlsQuery();
|
||||
const [ actualOrderBy, setActualOrderBy ] = useState(
|
||||
// This separated state handling is needed to be able to fall back to settings value, but only once when loaded
|
||||
orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING,
|
||||
);
|
||||
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
||||
|
||||
const refreshList = (extraParams: ShlinkShortUrlsListParams) => listShortUrls(
|
||||
{ ...shortUrlsListParams, ...extraParams },
|
||||
);
|
||||
const handleOrderBy = (field?: OrderableFields, dir?: OrderDir) => {
|
||||
setOrder({ field, dir });
|
||||
refreshList({ orderBy: field ? { [field]: dir } : undefined });
|
||||
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
|
||||
toFirstPage({ orderBy: { field, dir } });
|
||||
setActualOrderBy({ field, dir });
|
||||
};
|
||||
const orderByColumn = (field: OrderableFields) => () =>
|
||||
handleOrderBy(field, determineOrderDir(field, order.field, order.dir));
|
||||
const renderOrderIcon = (field: OrderableFields) => <TableOrderIcon currentOrder={order} field={field} />;
|
||||
const orderByColumn = (field: ShortUrlsOrderableFields) => () =>
|
||||
handleOrderBy(field, determineOrderDir(field, actualOrderBy.field, actualOrderBy.dir));
|
||||
const renderOrderIcon = (field: ShortUrlsOrderableFields) =>
|
||||
<TableOrderIcon currentOrder={actualOrderBy} field={field} />;
|
||||
const addTag = pipe(
|
||||
(newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','),
|
||||
(tags) => toFirstPage({ tags }),
|
||||
(newTag: string) => [ ...new Set([ ...tags, newTag ]) ],
|
||||
(updatedTags) => toFirstPage({ tags: updatedTags }),
|
||||
);
|
||||
|
||||
useEffect(() => resetShortUrlParams, []);
|
||||
useEffect(() => {
|
||||
refreshList(
|
||||
{ page: match.params.page, searchTerm: search, tags: selectedTags, itemsPerPage: undefined, startDate, endDate },
|
||||
);
|
||||
}, [ match.params.page, search, selectedTags, startDate, endDate ]);
|
||||
listShortUrls({
|
||||
page,
|
||||
searchTerm: search,
|
||||
tags,
|
||||
startDate,
|
||||
endDate,
|
||||
orderBy: actualOrderBy,
|
||||
tagsMode,
|
||||
});
|
||||
}, [ page, search, tags, startDate, endDate, actualOrderBy, tagsMode ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3"><SearchBar /></div>
|
||||
<div className="d-block d-lg-none mb-3">
|
||||
<SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={handleOrderBy} />
|
||||
</div>
|
||||
<ShortUrlsFilteringBar
|
||||
selectedServer={selectedServer}
|
||||
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
|
||||
order={actualOrderBy}
|
||||
handleOrderBy={handleOrderBy}
|
||||
className="mb-3"
|
||||
/>
|
||||
<Card body className="pb-1">
|
||||
<ShortUrlsTable
|
||||
selectedServer={selectedServer}
|
||||
|
||||
@@ -5,12 +5,12 @@ import { SelectedServer } from '../servers/data';
|
||||
import { supportsShortUrlTitle } from '../utils/helpers/features';
|
||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
|
||||
import { OrderableFields } from './reducers/shortUrlsListParams';
|
||||
import { ShortUrlsOrderableFields } from './data';
|
||||
import './ShortUrlsTable.scss';
|
||||
|
||||
export interface ShortUrlsTableProps {
|
||||
orderByColumn?: (column: OrderableFields) => () => void;
|
||||
renderOrderIcon?: (column: OrderableFields) => ReactNode;
|
||||
orderByColumn?: (column: ShortUrlsOrderableFields) => () => void;
|
||||
renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode;
|
||||
shortUrlsList: ShortUrlsListState;
|
||||
selectedServer: SelectedServer;
|
||||
onTagClick?: (tag: string) => void;
|
||||
@@ -28,7 +28,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||
const { error, loading, shortUrls } = shortUrlsList;
|
||||
const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn });
|
||||
const orderableColumnsClasses = classNames('short-urls-table__header-cell', actionableFieldClasses);
|
||||
const tableClasses = classNames('table table-hover', className);
|
||||
const tableClasses = classNames('table table-hover responsive-table', className);
|
||||
const supportsTitle = supportsShortUrlTitle(selectedServer);
|
||||
|
||||
const renderShortUrls = () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Nullable, OptionalString } from '../../utils/utils';
|
||||
import { Order } from '../../utils/helpers/ordering';
|
||||
|
||||
export interface EditShortUrlData {
|
||||
longUrl?: string;
|
||||
@@ -50,3 +51,24 @@ export interface ShortUrlIdentifier {
|
||||
shortCode: string;
|
||||
domain: OptionalString;
|
||||
}
|
||||
|
||||
export const SHORT_URLS_ORDERABLE_FIELDS = {
|
||||
dateCreated: 'Created at',
|
||||
shortCode: 'Short URL',
|
||||
longUrl: 'Long URL',
|
||||
title: 'Title',
|
||||
visits: 'Visits',
|
||||
};
|
||||
|
||||
export type ShortUrlsOrderableFields = keyof typeof SHORT_URLS_ORDERABLE_FIELDS;
|
||||
|
||||
export type ShortUrlsOrder = Order<ShortUrlsOrderableFields>;
|
||||
|
||||
export interface ExportableShortUrl {
|
||||
createdAt: string;
|
||||
title: string;
|
||||
shortUrl: string;
|
||||
longUrl: string;
|
||||
tags: string;
|
||||
visits: number;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
|
||||
if (error) {
|
||||
return (
|
||||
<Result type="error" className="mt-3">
|
||||
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />}
|
||||
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-end pointer" onClick={resetCreateShortUrl} />}
|
||||
<ShlinkApiError errorData={errorData} fallbackMessage="An error occurred while creating the URL :(" />
|
||||
</Result>
|
||||
);
|
||||
@@ -42,7 +42,7 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
|
||||
|
||||
return (
|
||||
<Result type="success" className="mt-3">
|
||||
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />}
|
||||
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-end pointer" onClick={resetCreateShortUrl} />}
|
||||
<b>Great!</b> The short URL is <b>{shortUrl}</b>
|
||||
|
||||
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
|
||||
|
||||
61
src/short-urls/helpers/ExportShortUrlsBtn.tsx
Normal file
61
src/short-urls/helpers/ExportShortUrlsBtn.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { FC } from 'react';
|
||||
import { ExportBtn } from '../../utils/ExportBtn';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { isServerWithId, SelectedServer } from '../../servers/data';
|
||||
import { ShortUrl } from '../data';
|
||||
import { ReportExporter } from '../../common/services/ReportExporter';
|
||||
import { useShortUrlsQuery } from './hooks';
|
||||
|
||||
export interface ExportShortUrlsBtnProps {
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
interface ExportShortUrlsBtnConnectProps extends ExportShortUrlsBtnProps {
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const itemsPerPage = 20;
|
||||
|
||||
export const ExportShortUrlsBtn = (
|
||||
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||
{ exportShortUrls }: ReportExporter,
|
||||
): FC<ExportShortUrlsBtnConnectProps> => ({ amount = 0, selectedServer }) => {
|
||||
const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery();
|
||||
const [ loading,, startLoading, stopLoading ] = useToggle();
|
||||
const exportAllUrls = async () => {
|
||||
if (!isServerWithId(selectedServer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = amount / itemsPerPage;
|
||||
const { listShortUrls } = buildShlinkApiClient(selectedServer);
|
||||
const loadAllUrls = async (page = 1): Promise<ShortUrl[]> => {
|
||||
const { data } = await listShortUrls(
|
||||
{ page: `${page}`, tags, searchTerm: search, startDate, endDate, orderBy, tagsMode, itemsPerPage },
|
||||
);
|
||||
|
||||
if (page >= totalPages) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// TODO Support paralelization
|
||||
return data.concat(await loadAllUrls(page + 1));
|
||||
};
|
||||
|
||||
startLoading();
|
||||
const shortUrls = await loadAllUrls();
|
||||
|
||||
exportShortUrls(shortUrls.map((shortUrl) => ({
|
||||
createdAt: shortUrl.dateCreated,
|
||||
shortUrl: shortUrl.shortUrl,
|
||||
longUrl: shortUrl.longUrl,
|
||||
title: shortUrl.title ?? '',
|
||||
tags: shortUrl.tags.join(','),
|
||||
visits: shortUrl.visitsCount,
|
||||
})));
|
||||
stopLoading();
|
||||
};
|
||||
|
||||
return <ExportBtn loading={loading} className="btn-md-block" amount={amount} onClick={exportAllUrls} />;
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import { Modal, FormGroup, ModalBody, ModalHeader, Row, Button } from 'reactstra
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import classNames from 'classnames';
|
||||
import { ShortUrlModalProps } from '../data';
|
||||
import { SelectedServer } from '../../servers/data';
|
||||
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
||||
@@ -56,10 +55,8 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Vers
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<Row>
|
||||
<FormGroup
|
||||
className={classNames({ 'col-md-4': willRenderThreeControls, 'col-md-6': !willRenderThreeControls })}
|
||||
>
|
||||
<label className="mb-0">Size: {size}px</label>
|
||||
<FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}>
|
||||
<label>Size: {size}px</label>
|
||||
<input
|
||||
type="range"
|
||||
className="form-control-range"
|
||||
@@ -71,8 +68,8 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Vers
|
||||
/>
|
||||
</FormGroup>
|
||||
{capabilities.marginIsSupported && (
|
||||
<FormGroup className={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}>
|
||||
<label className="mb-0">Margin: {margin}px</label>
|
||||
<FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}>
|
||||
<label>Margin: {margin}px</label>
|
||||
<input
|
||||
type="range"
|
||||
className="form-control-range"
|
||||
@@ -106,7 +103,7 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Vers
|
||||
color="primary"
|
||||
onClick={async () => imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`)}
|
||||
>
|
||||
Download <FontAwesomeIcon icon={downloadIcon} className="ml-1" />
|
||||
Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</ForServerVersion>
|
||||
|
||||
@@ -12,7 +12,7 @@ export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
|
||||
{ children, infoTooltip, checked, onChange },
|
||||
) => (
|
||||
<p>
|
||||
<Checkbox inline checked={checked} className={infoTooltip ? 'mr-2' : ''} onChange={onChange}>
|
||||
<Checkbox inline checked={checked} className={infoTooltip ? 'me-2' : ''} onChange={onChange}>
|
||||
{children}
|
||||
</Checkbox>
|
||||
{infoTooltip && <InfoTooltip placement="right">{infoTooltip}</InfoTooltip>}
|
||||
|
||||
@@ -59,7 +59,7 @@ const ShortUrlsRow = (
|
||||
<span className="indivisible short-urls-row__cell--relative">
|
||||
<ExternalLink href={shortUrl.shortUrl} />
|
||||
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
|
||||
<span className="badge badge-warning short-urls-row__copy-hint" hidden={!copiedToClipboard}>
|
||||
<span className="badge bg-warning text-black short-urls-row__copy-hint" hidden={!copiedToClipboard}>
|
||||
Copied short URL!
|
||||
</span>
|
||||
</span>
|
||||
@@ -73,7 +73,7 @@ const ShortUrlsRow = (
|
||||
</td>
|
||||
)}
|
||||
<td className="responsive-table__cell short-urls-row__cell" data-th="Tags">{renderTags(shortUrl.tags)}</td>
|
||||
<td className="responsive-table__cell short-urls-row__cell text-lg-right" data-th="Visits">
|
||||
<td className="responsive-table__cell short-urls-row__cell text-lg-end" data-th="Visits">
|
||||
<ShortUrlVisitsCount
|
||||
visitsCount={shortUrl.visitsCount}
|
||||
shortUrl={shortUrl}
|
||||
|
||||
@@ -1,30 +1,63 @@
|
||||
import { RouteChildrenProps } from 'react-router-dom';
|
||||
import { useParams, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useMemo } from 'react';
|
||||
import { isEmpty } from 'ramda';
|
||||
import { isEmpty, pipe } from 'ramda';
|
||||
import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
|
||||
import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
|
||||
import { orderToString, stringToOrder } from '../../utils/helpers/ordering';
|
||||
import { TagsFilteringMode } from '../../api/types';
|
||||
|
||||
type ServerIdRouteProps = RouteChildrenProps<{ serverId: string }>;
|
||||
type ToFirstPage = (extra: Partial<ShortUrlsQuery>) => void;
|
||||
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
|
||||
|
||||
export interface ShortUrlListRouteParams {
|
||||
page: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
interface ShortUrlsQuery {
|
||||
tags?: string;
|
||||
interface ShortUrlsQueryCommon {
|
||||
search?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
tagsMode?: TagsFilteringMode;
|
||||
}
|
||||
|
||||
export const useShortUrlsQuery = ({ history, location, match }: ServerIdRouteProps): [ShortUrlsQuery, ToFirstPage] => {
|
||||
const query = useMemo(() => parseQuery<ShortUrlsQuery>(location.search), [ location ]);
|
||||
const toFirstPageWithExtra = (extra: Partial<ShortUrlsQuery>) => {
|
||||
const evolvedQuery = stringifyQuery({ ...query, ...extra });
|
||||
interface ShortUrlsQuery extends ShortUrlsQueryCommon {
|
||||
orderBy?: string;
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
|
||||
orderBy?: ShortUrlsOrder;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const params = useParams<{ serverId: string }>();
|
||||
|
||||
const query = useMemo(
|
||||
pipe(
|
||||
() => parseQuery<ShortUrlsQuery>(location.search),
|
||||
({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => {
|
||||
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
|
||||
const parsedTags = tags?.split(',') ?? [];
|
||||
|
||||
return { ...rest, orderBy: parsedOrderBy, tags: parsedTags };
|
||||
},
|
||||
),
|
||||
[ location.search ],
|
||||
);
|
||||
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
|
||||
const { orderBy, tags, ...mergedQuery } = { ...query, ...extra };
|
||||
const normalizedQuery: ShortUrlsQuery = {
|
||||
...mergedQuery,
|
||||
orderBy: orderBy && orderToString(orderBy),
|
||||
tags: tags.length > 0 ? tags.join(',') : undefined,
|
||||
};
|
||||
const evolvedQuery = stringifyQuery(normalizedQuery);
|
||||
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;
|
||||
|
||||
history.push(`/server/${match?.params.serverId}/list-short-urls/1${queryString}`);
|
||||
navigate(`/server/${params.serverId}/list-short-urls/1${queryString}`);
|
||||
};
|
||||
|
||||
return [ query, toFirstPageWithExtra ];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { assoc, assocPath, init, last, pipe, reject } from 'ramda';
|
||||
import { assoc, assocPath, last, pipe, reject } from 'ramda';
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { shortUrlMatches } from '../helpers';
|
||||
import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
|
||||
@@ -7,7 +7,6 @@ import { GetState } from '../../container/types';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types';
|
||||
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
|
||||
import { ShortUrlsListParams } from './shortUrlsListParams';
|
||||
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
||||
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
|
||||
|
||||
@@ -17,6 +16,8 @@ export const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR
|
||||
export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export const ITEMS_IN_OVERVIEW_PAGE = 5;
|
||||
|
||||
export interface ShortUrlsList {
|
||||
shortUrls?: ShlinkShortUrlsResponse;
|
||||
loading: boolean;
|
||||
@@ -25,7 +26,6 @@ export interface ShortUrlsList {
|
||||
|
||||
export interface ListShortUrlsAction extends Action<string> {
|
||||
shortUrls: ShlinkShortUrlsResponse;
|
||||
params: ShortUrlsListParams;
|
||||
}
|
||||
|
||||
export type ListShortUrlsCombinedAction = (
|
||||
@@ -77,10 +77,11 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
|
||||
),
|
||||
[CREATE_SHORT_URL]: pipe(
|
||||
// The only place where the list and the creation form coexist is the overview page.
|
||||
// There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL and remove the last one.
|
||||
// There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL.
|
||||
// We can also remove the items above the amount that is displayed there.
|
||||
(state: ShortUrlsList, { result }: CreateShortUrlAction) => !state.shortUrls ? state : assocPath(
|
||||
[ 'shortUrls', 'data' ],
|
||||
[ result, ...init(state.shortUrls.data) ],
|
||||
[ result, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1) ],
|
||||
state,
|
||||
),
|
||||
(state: ShortUrlsList) => !state.shortUrls ? state : assocPath(
|
||||
@@ -109,8 +110,8 @@ export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||
try {
|
||||
const shortUrls = await listShortUrls(params);
|
||||
|
||||
dispatch<ListShortUrlsAction>({ type: LIST_SHORT_URLS, shortUrls, params });
|
||||
dispatch<ListShortUrlsAction>({ type: LIST_SHORT_URLS, shortUrls });
|
||||
} catch (e) {
|
||||
dispatch({ type: LIST_SHORT_URLS_ERROR, params });
|
||||
dispatch({ type: LIST_SHORT_URLS_ERROR });
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user