mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-13 11:03:50 +00:00
Compare commits
138 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 |
@@ -1,4 +1,5 @@
|
|||||||
./.github
|
./.github
|
||||||
|
./.stryker-tmp
|
||||||
./build
|
./build
|
||||||
./coverage
|
./coverage
|
||||||
./node_modules
|
./node_modules
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
"ignorePatterns": ["src/service*.ts"],
|
"ignorePatterns": ["src/service*.ts"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"complexity": "off",
|
"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:
|
with:
|
||||||
node-version: 16.13
|
node-version: 16.13
|
||||||
- name: Generate release assets
|
- name: Generate release assets
|
||||||
run: npm ci && npm run build ${GITHUB_REF#refs/tags/v}
|
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
|
||||||
- name: Publish release with assets
|
- name: Publish release with assets
|
||||||
uses: docker://antonyurchenko/git-release:latest
|
uses: docker://antonyurchenko/git-release:latest
|
||||||
env:
|
env:
|
||||||
|
|||||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -4,6 +4,51 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [3.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
|
## [3.5.0] - 2022-01-01
|
||||||
### Added
|
### 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".
|
* [#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".
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ FROM node:16.13-alpine as node
|
|||||||
COPY . /shlink-web-client
|
COPY . /shlink-web-client
|
||||||
ARG VERSION="latest"
|
ARG VERSION="latest"
|
||||||
ENV VERSION ${VERSION}
|
ENV VERSION ${VERSION}
|
||||||
RUN cd /shlink-web-client && \
|
RUN cd /shlink-web-client && npm ci && npm run build
|
||||||
npm install && npm run build -- ${VERSION} --no-dist
|
|
||||||
|
|
||||||
FROM nginx:1.21-alpine
|
FROM nginx:1.21-alpine
|
||||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||||
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||||
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
||||||
|
[](https://twitter.com/shlinkio)
|
||||||
[](https://slnk.to/donate)
|
[](https://slnk.to/donate)
|
||||||
|
|
||||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
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,
|
lines: 85,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resolver: 'jest-pnp-resolver',
|
setupFiles: [ '<rootDir>/config/setupEnzyme.js' ],
|
||||||
setupFiles: [
|
testMatch: [ '<rootDir>/test/**/*.test.{ts,tsx}' ],
|
||||||
'react-app-polyfill/jsdom',
|
|
||||||
'<rootDir>/config/setupEnzyme.js',
|
|
||||||
],
|
|
||||||
testMatch: [ '<rootDir>/test/**/*.test.{js,jsx,ts,tsx}' ],
|
|
||||||
testEnvironment: 'jsdom',
|
testEnvironment: 'jsdom',
|
||||||
testURL: 'http://localhost',
|
testURL: 'http://localhost',
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.(ts|tsx|js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
|
'^.+\\.(ts|tsx|js)$': '<rootDir>/node_modules/babel-jest',
|
||||||
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
|
'^(?!.*\\.(ts|tsx|js|json)$)': '<rootDir>/config/jest/fileTransform.js',
|
||||||
'^(?!.*\\.(ts|tsx|js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
|
|
||||||
},
|
},
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
|
'<rootDir>/.stryker-tmp',
|
||||||
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
|
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
|
||||||
'^.+\\.module\\.(css|sass|scss)$',
|
'^.+\\.module\\.scss$',
|
||||||
],
|
],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^react-native$': 'react-native-web',
|
'^.+\\.module\\.scss$': 'identity-obj-proxy',
|
||||||
'^.+\\.module\\.(css|sass|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: [
|
moduleFileExtensions: [ 'js', 'ts', 'tsx', 'json' ],
|
||||||
'web.js',
|
|
||||||
'js',
|
|
||||||
'web.ts',
|
|
||||||
'ts',
|
|
||||||
'web.tsx',
|
|
||||||
'tsx',
|
|
||||||
'json',
|
|
||||||
'web.jsx',
|
|
||||||
'jsx',
|
|
||||||
'node',
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
16646
package-lock.json
generated
16646
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",
|
"lint:js:fix": "npm run lint:js -- --fix",
|
||||||
"start": "node scripts/start.js",
|
"start": "node scripts/start.js",
|
||||||
"serve:build": "serve ./build",
|
"serve:build": "serve ./build",
|
||||||
"build": "node scripts/build.js",
|
"build": "node scripts/build.js && 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": "node scripts/test.js --env=jsdom --colors --verbose",
|
||||||
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
|
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
|
||||||
"test:ci": "npm run test:coverage -- --coverageReporters=clover",
|
"test:ci": "npm run test:coverage -- --coverageReporters=clover",
|
||||||
@@ -22,84 +23,81 @@
|
|||||||
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
|
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^5.15.2",
|
"@fortawesome/fontawesome-free": "^6.0.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^1.2.34",
|
"@fortawesome/fontawesome-svg-core": "^1.3.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^5.15.2",
|
"@fortawesome/free-regular-svg-icons": "^6.0.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
"@fortawesome/free-solid-svg-icons": "^6.0.0",
|
||||||
"@fortawesome/react-fontawesome": "^0.1.14",
|
"@fortawesome/react-fontawesome": "^0.1.17",
|
||||||
"axios": "^0.21.2",
|
"axios": "^0.26.0",
|
||||||
"bootstrap": "^4.6.0",
|
"bootstrap": "^5.1.3",
|
||||||
"bottlejs": "^2.0.0",
|
"bottlejs": "^2.0.0",
|
||||||
"bowser": "^2.11.0",
|
"bowser": "^2.11.0",
|
||||||
"chart.js": "^3.5.1",
|
"chart.js": "^3.7.1",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.3.1",
|
||||||
"compare-versions": "^3.6.0",
|
"compare-versions": "^4.1.3",
|
||||||
"csvjson": "^5.1.0",
|
"csvjson": "^5.1.0",
|
||||||
"date-fns": "^2.22.1",
|
"date-fns": "^2.28.0",
|
||||||
"event-source-polyfill": "^1.0.22",
|
"event-source-polyfill": "^1.0.25",
|
||||||
"leaflet": "^1.7.1",
|
"leaflet": "^1.7.1",
|
||||||
"promise": "^8.1.0",
|
|
||||||
"qs": "^6.9.6",
|
"qs": "^6.9.6",
|
||||||
"ramda": "^0.27.1",
|
"ramda": "^0.27.2",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.2",
|
||||||
"react-chartjs-2": "^3.0.4",
|
"react-chartjs-2": "^3.3.0",
|
||||||
"react-color": "^2.19.3",
|
"react-colorful": "^5.5.1",
|
||||||
"react-copy-to-clipboard": "^5.0.2",
|
"react-copy-to-clipboard": "^5.0.4",
|
||||||
"react-datepicker": "^3.6.0",
|
"react-datepicker": "^4.7.0",
|
||||||
"react-dom": "^17.0.1",
|
"react-dom": "^17.0.2",
|
||||||
"react-external-link": "^1.2.0",
|
"react-external-link": "^1.2.2",
|
||||||
"react-leaflet": "^3.1.0",
|
"react-leaflet": "^3.2.5",
|
||||||
"react-redux": "^7.2.2",
|
"react-redux": "^7.2.6",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^6.2.2",
|
||||||
"react-swipeable": "^6.0.1",
|
"react-swipeable": "^6.2.0",
|
||||||
"react-tag-autocomplete": "^6.1.0",
|
"react-tag-autocomplete": "^6.3.0",
|
||||||
"reactstrap": "^8.9.0",
|
"reactstrap": "^9.0.1",
|
||||||
"redux": "^4.0.5",
|
"redux": "^4.1.2",
|
||||||
"redux-localstorage-simple": "^2.4.0",
|
"redux-localstorage-simple": "^2.4.1",
|
||||||
"redux-thunk": "^2.3.0",
|
"redux-thunk": "^2.4.1",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"workbox-core": "^6.1.5",
|
"workbox-core": "^6.5.1",
|
||||||
"workbox-expiration": "^6.1.5",
|
"workbox-expiration": "^6.5.1",
|
||||||
"workbox-precaching": "^6.1.5",
|
"workbox-precaching": "^6.5.1",
|
||||||
"workbox-routing": "^6.1.5",
|
"workbox-routing": "^6.5.1",
|
||||||
"workbox-strategies": "^6.1.5"
|
"workbox-strategies": "^6.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.13.8",
|
"@babel/core": "^7.17.5",
|
||||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
|
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7",
|
||||||
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
|
"@babel/plugin-proposal-optional-chaining": "^7.16.7",
|
||||||
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
|
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
|
||||||
"@stryker-mutator/core": "^5.4.1",
|
"@stryker-mutator/core": "^5.6.1",
|
||||||
"@stryker-mutator/jest-runner": "^5.4.1",
|
"@stryker-mutator/jest-runner": "^5.6.1",
|
||||||
"@stryker-mutator/typescript-checker": "^5.4.1",
|
"@stryker-mutator/typescript-checker": "^5.6.1",
|
||||||
"@svgr/webpack": "^5.5.0",
|
"@svgr/webpack": "^5.5.0",
|
||||||
"@types/classnames": "^2.2.11",
|
"@types/classnames": "^2.3.1",
|
||||||
"@types/enzyme": "^3.10.10",
|
"@types/enzyme": "^3.10.11",
|
||||||
"@types/jest": "^27.0.2",
|
"@types/jest": "^27.4.1",
|
||||||
"@types/leaflet": "^1.5.23",
|
"@types/leaflet": "^1.7.9",
|
||||||
"@types/qs": "^6.9.5",
|
"@types/qs": "^6.9.7",
|
||||||
"@types/ramda": "^0.27.38",
|
"@types/ramda": "0.27.38",
|
||||||
"@types/react": "^17.0.2",
|
"@types/react": "^17.0.39",
|
||||||
"@types/react-color": "^3.0.4",
|
"@types/react-color": "^3.0.6",
|
||||||
"@types/react-copy-to-clipboard": "^5.0.0",
|
"@types/react-copy-to-clipboard": "^5.0.2",
|
||||||
"@types/react-datepicker": "^3.1.5",
|
"@types/react-datepicker": "^4.3.4",
|
||||||
"@types/react-dom": "^17.0.1",
|
"@types/react-dom": "^17.0.13",
|
||||||
"@types/react-leaflet": "^2.5.2",
|
"@types/react-leaflet": "^2.8.2",
|
||||||
"@types/react-redux": "^7.1.16",
|
"@types/react-redux": "^7.1.23",
|
||||||
"@types/react-router-dom": "^5.1.7",
|
"@types/react-tag-autocomplete": "^6.1.1",
|
||||||
"@types/react-tag-autocomplete": "^6.1.0",
|
"@types/uuid": "^8.3.4",
|
||||||
"@types/uuid": "^8.3.0",
|
"@wojtekmaj/enzyme-adapter-react-17": "0.6.5",
|
||||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
|
"adm-zip": "^0.5.9",
|
||||||
"adm-zip": "^0.4.16",
|
"autoprefixer": "^10.4.2",
|
||||||
"autoprefixer": "^10.0.2",
|
"babel-jest": "^27.5.1",
|
||||||
"babel-core": "7.0.0-bridge.0",
|
"babel-loader": "^8.2.3",
|
||||||
"babel-jest": "^27.3.1",
|
"babel-plugin-named-asset-import": "^0.3.8",
|
||||||
"babel-loader": "^8.2.1",
|
"babel-preset-react-app": "10.0.0",
|
||||||
"babel-plugin-named-asset-import": "^0.3.7",
|
|
||||||
"babel-preset-react-app": "^10.0.0",
|
|
||||||
"babel-runtime": "^6.26.0",
|
"babel-runtime": "^6.26.0",
|
||||||
"bfj": "^7.0.2",
|
"bfj": "^7.0.2",
|
||||||
"case-sensitive-paths-webpack-plugin": "^2.3.0",
|
"case-sensitive-paths-webpack-plugin": "^2.4.0",
|
||||||
"chalk": "^4.1.2",
|
"chalk": "^4.1.2",
|
||||||
"css-loader": "^5.0.1",
|
"css-loader": "^5.0.1",
|
||||||
"dart-sass": "^1.25.0",
|
"dart-sass": "^1.25.0",
|
||||||
@@ -113,26 +111,22 @@
|
|||||||
"fs-extra": "^9.0.1",
|
"fs-extra": "^9.0.1",
|
||||||
"html-webpack-plugin": "^4.5.0",
|
"html-webpack-plugin": "^4.5.0",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^27.3.1",
|
"jest": "^27.5.1",
|
||||||
"jest-pnp-resolver": "^1.2.2",
|
|
||||||
"jest-resolve": "^27.3.1",
|
|
||||||
"mini-css-extract-plugin": "^1.3.1",
|
"mini-css-extract-plugin": "^1.3.1",
|
||||||
"object-assign": "^4.1.1",
|
"object-assign": "^4.1.1",
|
||||||
"optimize-css-assets-webpack-plugin": "^5.0.4",
|
"optimize-css-assets-webpack-plugin": "^5.0.4",
|
||||||
"pnp-webpack-plugin": "^1.6.4",
|
"pnp-webpack-plugin": "^1.7.0",
|
||||||
"postcss": "^8.1.7",
|
"postcss": "^8.4.8",
|
||||||
"postcss-flexbugs-fixes": "^4.2.1",
|
"postcss-flexbugs-fixes": "^4.2.1",
|
||||||
"postcss-loader": "^3.0.0",
|
"postcss-loader": "^3.0.0",
|
||||||
"postcss-preset-env": "^6.7.0",
|
"postcss-preset-env": "^6.7.0",
|
||||||
"postcss-safe-parser": "^5.0.2",
|
"postcss-safe-parser": "^5.0.2",
|
||||||
"raf": "^3.4.1",
|
|
||||||
"react-app-polyfill": "^2.0.0",
|
|
||||||
"react-dev-utils": "^11.0.0",
|
"react-dev-utils": "^11.0.0",
|
||||||
"resolve": "^1.19.0",
|
"resolve": "^1.22.0",
|
||||||
"sass": "^1.29.0",
|
"sass": "^1.49.9",
|
||||||
"sass-loader": "^10.1.0",
|
"sass-loader": "^10.1.0",
|
||||||
"serve": "^12.0.0",
|
"serve": "^12.0.0",
|
||||||
"stryker-cli": "^1.0.0",
|
"stryker-cli": "^1.0.2",
|
||||||
"style-loader": "^2.0.0",
|
"style-loader": "^2.0.0",
|
||||||
"stylelint": "^13.7.2",
|
"stylelint": "^13.7.2",
|
||||||
"stylelint-config-adidas": "^1.3.0",
|
"stylelint-config-adidas": "^1.3.0",
|
||||||
@@ -141,29 +135,14 @@
|
|||||||
"stylelint-scss": "^3.18.0",
|
"stylelint-scss": "^3.18.0",
|
||||||
"sw-precache-webpack-plugin": "^1.0.0",
|
"sw-precache-webpack-plugin": "^1.0.0",
|
||||||
"terser-webpack-plugin": "^4.2.3",
|
"terser-webpack-plugin": "^4.2.3",
|
||||||
"ts-jest": "^27.0.7",
|
|
||||||
"ts-mockery": "^1.2.0",
|
"ts-mockery": "^1.2.0",
|
||||||
"typescript": "^4.4.4",
|
"typescript": "^4.6.2",
|
||||||
"url-loader": "^4.1.1",
|
"url-loader": "^4.1.1",
|
||||||
"webpack": "^4.44.2",
|
"webpack": "^4.46.0",
|
||||||
"webpack-dev-server": "^3.11.0",
|
"webpack-dev-server": "^3.11.3",
|
||||||
"webpack-manifest-plugin": "^2.2.0",
|
"webpack-manifest-plugin": "^2.2.0",
|
||||||
"whatwg-fetch": "^3.5.0",
|
"whatwg-fetch": "^3.6.2",
|
||||||
"workbox-webpack-plugin": "^6.1.5"
|
"workbox-webpack-plugin": "^6.5.1"
|
||||||
},
|
|
||||||
"babel": {
|
|
||||||
"presets": [
|
|
||||||
[
|
|
||||||
"react-app",
|
|
||||||
{
|
|
||||||
"runtime": "automatic"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"plugins": [
|
|
||||||
"@babel/plugin-proposal-optional-chaining",
|
|
||||||
"@babel/plugin-proposal-nullish-coalescing-operator"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
">0.2%",
|
">0.2%",
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const chalk = require('chalk');
|
|||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const webpack = require('webpack');
|
const webpack = require('webpack');
|
||||||
const bfj = require('bfj');
|
const bfj = require('bfj');
|
||||||
const AdmZip = require('adm-zip');
|
|
||||||
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
|
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
|
||||||
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
|
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
|
||||||
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
|
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
|
||||||
@@ -44,8 +43,6 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
|
|||||||
const argvSliceStart = 2;
|
const argvSliceStart = 2;
|
||||||
const argv = process.argv.slice(argvSliceStart);
|
const argv = process.argv.slice(argvSliceStart);
|
||||||
const writeStatsJson = argv.includes('--stats');
|
const writeStatsJson = argv.includes('--stats');
|
||||||
const withoutDist = argv.includes('--no-dist');
|
|
||||||
const { version, hasVersion } = getVersionFromArgs(argv);
|
|
||||||
|
|
||||||
// Generate configuration
|
// Generate configuration
|
||||||
const config = configFactory('production');
|
const config = configFactory('production');
|
||||||
@@ -84,7 +81,6 @@ checkBrowsers(paths.appPath, isInteractive)
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log(chalk.green('Compiled successfully.\n'));
|
console.log(chalk.green('Compiled successfully.\n'));
|
||||||
hasVersion && replaceVersionPlaceholder(version);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('File sizes after gzip:\n');
|
console.log('File sizes after gzip:\n');
|
||||||
@@ -103,7 +99,6 @@ checkBrowsers(paths.appPath, isInteractive)
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.then(() => hasVersion && !withoutDist && zipDist(version))
|
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (err && err.message) {
|
if (err && err.message) {
|
||||||
console.log(err.message);
|
console.log(err.message);
|
||||||
@@ -185,43 +180,3 @@ function copyPublicFolder() {
|
|||||||
filter: (file) => file !== paths.appHtml,
|
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' {
|
declare module 'csvjson' {
|
||||||
export declare class CsvJson {
|
export declare class CsvJson {
|
||||||
public toObject<T>(content: string): T[];
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ export default class ShlinkApiClient {
|
|||||||
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query)
|
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query)
|
||||||
.then(({ data }) => data.visits);
|
.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> =>
|
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
|
||||||
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
|
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
|
||||||
.then(({ data }) => data.visits);
|
.then(({ data }) => data.visits);
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export interface ShlinkDomainsResponse {
|
|||||||
defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10
|
defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TagsFilteringMode = 'all' | 'any';
|
||||||
|
|
||||||
export interface ShlinkShortUrlsListParams {
|
export interface ShlinkShortUrlsListParams {
|
||||||
page?: string;
|
page?: string;
|
||||||
itemsPerPage?: number;
|
itemsPerPage?: number;
|
||||||
@@ -94,6 +96,7 @@ export interface ShlinkShortUrlsListParams {
|
|||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
orderBy?: ShortUrlsOrder;
|
orderBy?: ShortUrlsOrder;
|
||||||
|
tagsMode?: TagsFilteringMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> {
|
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> {
|
||||||
@@ -114,6 +117,6 @@ export interface InvalidArgumentError extends ProblemDetailsError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface InvalidShortUrlDeletion extends ProblemDetailsError {
|
export interface InvalidShortUrlDeletion extends ProblemDetailsError {
|
||||||
type: 'INVALID_SHORTCODE_DELETION';
|
type: 'INVALID_SHORTCODE_DELETION' | 'INVALID_SHORT_URL_DELETION';
|
||||||
threshold: number;
|
threshold: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,4 @@ export const isInvalidArgumentError = (error?: ProblemDetailsError): error is In
|
|||||||
error?.type === 'INVALID_ARGUMENT';
|
error?.type === 'INVALID_ARGUMENT';
|
||||||
|
|
||||||
export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion =>
|
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 { 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 classNames from 'classnames';
|
||||||
import NotFound from '../common/NotFound';
|
import NotFound from '../common/NotFound';
|
||||||
import { ServersMap } from '../servers/data';
|
import { ServersMap } from '../servers/data';
|
||||||
@@ -9,7 +9,7 @@ import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
|||||||
import { forceUpdate } from '../utils/helpers/sw';
|
import { forceUpdate } from '../utils/helpers/sw';
|
||||||
import './App.scss';
|
import './App.scss';
|
||||||
|
|
||||||
interface AppProps extends RouteChildrenProps {
|
interface AppProps {
|
||||||
fetchServers: () => void;
|
fetchServers: () => void;
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
@@ -26,7 +26,8 @@ const App = (
|
|||||||
Settings: FC,
|
Settings: FC,
|
||||||
ManageServers: FC,
|
ManageServers: FC,
|
||||||
ShlinkVersionsContainer: FC,
|
ShlinkVersionsContainer: FC,
|
||||||
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate, location }: AppProps) => {
|
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
|
||||||
|
const location = useLocation();
|
||||||
const isHome = location.pathname === '/';
|
const isHome = location.pathname === '/';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,15 +45,15 @@ const App = (
|
|||||||
|
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<div className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
|
<div className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
|
||||||
<Switch>
|
<Routes>
|
||||||
<Route exact path="/" component={Home} />
|
<Route index element={<Home />} />
|
||||||
<Route exact path="/settings" component={Settings} />
|
<Route path="/settings/*" element={<Settings />} />
|
||||||
<Route exact path="/manage-servers" component={ManageServers} />
|
<Route path="/manage-servers" element={<ManageServers />} />
|
||||||
<Route exact path="/server/create" component={CreateServer} />
|
<Route path="/server/create" element={<CreateServer />} />
|
||||||
<Route exact path="/server/:serverId/edit" component={EditServer} />
|
<Route path="/server/:serverId/edit" element={<EditServer />} />
|
||||||
<Route path="/server/:serverId" component={MenuLayout} />
|
<Route path="/server/:serverId/*" element={<MenuLayout />} />
|
||||||
<Route component={NotFound} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Switch>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shlink-footer">
|
<div className="shlink-footer">
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Bottle, { Decorator } from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
|
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
|
||||||
import App from '../App';
|
import App from '../App';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory(
|
bottle.serviceFactory(
|
||||||
'App',
|
'App',
|
||||||
@@ -18,7 +18,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
|||||||
'ShlinkVersionsContainer',
|
'ShlinkVersionsContainer',
|
||||||
);
|
);
|
||||||
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ]));
|
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ]));
|
||||||
bottle.decorator('App', withRouter);
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);
|
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forc
|
|||||||
<h4 className="mb-4">This app has just been updated!</h4>
|
<h4 className="mb-4">This app has just been updated!</h4>
|
||||||
<p className="mb-0">
|
<p className="mb-0">
|
||||||
Restart it to enjoy the new features.
|
Restart it to enjoy the new features.
|
||||||
<Button disabled={isUpdating} className="ml-2" color="secondary" size="sm" onClick={update}>
|
<Button disabled={isUpdating} className="ms-2" color="secondary" size="sm" onClick={update}>
|
||||||
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ml-1" /></>}
|
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ms-1" /></>}
|
||||||
{isUpdating && <>Restarting...</>}
|
{isUpdating && <>Restarting...</>}
|
||||||
</Button>
|
</Button>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import {
|
|||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { NavLink, NavLinkProps } from 'react-router-dom';
|
import { NavLink, NavLinkProps, useLocation } from 'react-router-dom';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Location } from 'history';
|
|
||||||
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
||||||
import { isServerWithId, SelectedServer } from '../servers/data';
|
import { isServerWithId, SelectedServer } from '../servers/data';
|
||||||
import { supportsDomainRedirects } from '../utils/helpers/features';
|
import { supportsDomainRedirects } from '../utils/helpers/features';
|
||||||
@@ -28,8 +27,7 @@ interface AsideMenuItemProps extends NavLinkProps {
|
|||||||
|
|
||||||
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
|
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
className={classNames('aside-menu__item', className)}
|
className={({ isActive }) => classNames('aside-menu__item', className, { 'aside-menu__item--selected': isActive })}
|
||||||
activeClassName="aside-menu__item--selected"
|
|
||||||
to={to}
|
to={to}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
@@ -42,11 +40,11 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
|||||||
) => {
|
) => {
|
||||||
const hasId = isServerWithId(selectedServer);
|
const hasId = isServerWithId(selectedServer);
|
||||||
const serverId = hasId ? selectedServer.id : '';
|
const serverId = hasId ? selectedServer.id : '';
|
||||||
|
const { pathname } = useLocation();
|
||||||
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
|
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
|
||||||
const asideClass = classNames('aside-menu', {
|
const asideClass = classNames('aside-menu', {
|
||||||
'aside-menu--hidden': !showOnMobile,
|
'aside-menu--hidden': !showOnMobile,
|
||||||
});
|
});
|
||||||
const shortUrlsIsActive = (_: null, location: Location) => location.pathname.match('/list-short-urls') !== null;
|
|
||||||
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
|
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,7 +54,10 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
|||||||
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
|
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
|
||||||
<span className="aside-menu__item-text">Overview</span>
|
<span className="aside-menu__item-text">Overview</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
<AsideMenuItem
|
||||||
|
to={buildPath('/list-short-urls/1')}
|
||||||
|
className={classNames({ 'aside-menu__item--selected': pathname.match('/list-short-urls') !== null })}
|
||||||
|
>
|
||||||
<FontAwesomeIcon fixedWidth icon={listIcon} />
|
<FontAwesomeIcon fixedWidth icon={listIcon} />
|
||||||
<span className="aside-menu__item-text">List short URLs</span>
|
<span className="aside-menu__item-text">List short URLs</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
@import '../utils/base';
|
@import '../utils/base';
|
||||||
@import '../utils/mixins/vertical-align';
|
@import '../utils/mixins/vertical-align';
|
||||||
|
|
||||||
|
$mainCardWidth: 720px;
|
||||||
|
$fiveColumnsSize: .4167; // 12 / 5 -> Can't use "/" operator in latest dart-sass
|
||||||
|
|
||||||
.home {
|
.home {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
@@ -12,19 +15,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home__logo-wrapper {
|
||||||
|
padding: 1.5rem !important;
|
||||||
|
height: 100% !important;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
.home__logo {
|
.home__logo {
|
||||||
@include vertical-align();
|
@include vertical-align();
|
||||||
|
|
||||||
|
width: calc(#{$mainCardWidth * $fiveColumnsSize} - 3rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home__main-card {
|
.home__main-card {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: 720px;
|
max-width: $mainCardWidth;
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
@media (min-width: $mdMin) {
|
||||||
@include vertical-align();
|
@include vertical-align();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home__title-wrapper {
|
||||||
|
padding: 1.5rem !important;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
.home__title {
|
.home__title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { isEmpty, values } from 'ramda';
|
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 { Card, Row } from 'reactstrap';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
@@ -10,11 +10,12 @@ import { ServersMap } from '../servers/data';
|
|||||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
import './Home.scss';
|
import './Home.scss';
|
||||||
|
|
||||||
export interface HomeProps extends RouteChildrenProps {
|
export interface HomeProps {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Home = ({ servers, history }: HomeProps) => {
|
const Home = ({ servers }: HomeProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const serversList = values(servers);
|
const serversList = values(servers);
|
||||||
const hasServers = !isEmpty(serversList);
|
const hasServers = !isEmpty(serversList);
|
||||||
|
|
||||||
@@ -22,20 +23,22 @@ const Home = ({ servers, history }: HomeProps) => {
|
|||||||
// Try to redirect to the first server marked as auto-connect
|
// Try to redirect to the first server marked as auto-connect
|
||||||
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
|
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
|
||||||
|
|
||||||
autoConnectServer && history.push(`/server/${autoConnectServer.id}`);
|
autoConnectServer && navigate(`/server/${autoConnectServer.id}`);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
<Card className="home__main-card">
|
<Card className="home__main-card">
|
||||||
<Row noGutters>
|
<Row className="g-0">
|
||||||
<div className="col-md-5 d-none d-md-block">
|
<div className="col-md-5 d-none d-md-block">
|
||||||
<div className="p-4">
|
<div className="home__logo-wrapper">
|
||||||
|
<div className="home__logo">
|
||||||
<ShlinkLogo />
|
<ShlinkLogo />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="col-md-7 home__servers-container">
|
<div className="col-md-7 home__servers-container">
|
||||||
<div className="p-4">
|
<div className="home__title-wrapper">
|
||||||
<h1 className="home__title">Welcome!</h1>
|
<h1 className="home__title">Welcome!</h1>
|
||||||
</div>
|
</div>
|
||||||
<ServersListGroup embedded servers={serversList}>
|
<ServersListGroup embedded servers={serversList}>
|
||||||
@@ -43,14 +46,14 @@ const Home = ({ servers, history }: HomeProps) => {
|
|||||||
<div className="p-4 text-center">
|
<div className="p-4 text-center">
|
||||||
<p className="mb-5">This application will help you manage your Shlink servers.</p>
|
<p className="mb-5">This application will help you manage your Shlink servers.</p>
|
||||||
<p>
|
<p>
|
||||||
<Link to="/server/create" className="btn btn-outline-primary btn-lg mr-2">
|
<Link to="/server/create" className="btn btn-outline-primary btn-lg me-2">
|
||||||
<FontAwesomeIcon icon={faPlus} /> <span className="ml-1">Add a server</span>
|
<FontAwesomeIcon icon={faPlus} /> <span className="ms-1">Add a server</span>
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-0 mt-5">
|
<p className="mb-0 mt-5">
|
||||||
<ExternalLink href="https://shlink.io/documentation">
|
<ExternalLink href="https://shlink.io/documentation">
|
||||||
<small>
|
<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>
|
</small>
|
||||||
</ExternalLink>
|
</ExternalLink>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FC, useEffect } from 'react';
|
import { 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 { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { RouteComponentProps } from 'react-router';
|
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
import './MainHeader.scss';
|
import './MainHeader.scss';
|
||||||
|
|
||||||
const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps) => {
|
const MainHeader = (ServersDropdown: FC) => () => {
|
||||||
const [ isOpen, toggleOpen, , close ] = useToggle();
|
const [ isOpen, toggleOpen, , close ] = useToggle();
|
||||||
|
const location = useLocation();
|
||||||
const { pathname } = location;
|
const { pathname } = location;
|
||||||
|
|
||||||
useEffect(close, [ location ]);
|
useEffect(close, [ location ]);
|
||||||
@@ -29,9 +29,9 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
|
|||||||
</NavbarToggler>
|
</NavbarToggler>
|
||||||
|
|
||||||
<Collapse navbar isOpen={isOpen}>
|
<Collapse navbar isOpen={isOpen}>
|
||||||
<Nav navbar className="ml-auto">
|
<Nav navbar className="ms-auto">
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
|
||||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
@import '../utils/base';
|
@import '../utils/base';
|
||||||
|
|
||||||
.menu-layout__swipeable {
|
.menu-layout__swipeable {
|
||||||
$offset: 15px;
|
|
||||||
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin-right: -$offset;
|
|
||||||
margin-left: -$offset;
|
|
||||||
padding-left: $offset;
|
|
||||||
padding-right: $offset;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-layout__swipeable-inner {
|
.menu-layout__swipeable-inner {
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { FC, useEffect } from 'react';
|
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 { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||||
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
|
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 { isReachableServer } from '../servers/data';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
import { AsideMenuProps } from './AsideMenu';
|
import { AsideMenuProps } from './AsideMenu';
|
||||||
import './MenuLayout.scss';
|
import './MenuLayout.scss';
|
||||||
|
|
||||||
|
interface MenuLayoutProps {
|
||||||
|
sidebarPresent: Function;
|
||||||
|
sidebarNotPresent: Function;
|
||||||
|
}
|
||||||
|
|
||||||
const MenuLayout = (
|
const MenuLayout = (
|
||||||
TagsList: FC,
|
TagsList: FC,
|
||||||
ShortUrlsList: FC,
|
ShortUrlsList: FC,
|
||||||
@@ -19,20 +24,29 @@ const MenuLayout = (
|
|||||||
ShortUrlVisits: FC,
|
ShortUrlVisits: FC,
|
||||||
TagVisits: FC,
|
TagVisits: FC,
|
||||||
OrphanVisits: FC,
|
OrphanVisits: FC,
|
||||||
|
NonOrphanVisits: FC,
|
||||||
ServerError: FC,
|
ServerError: FC,
|
||||||
Overview: FC,
|
Overview: FC,
|
||||||
EditShortUrl: FC,
|
EditShortUrl: FC,
|
||||||
ManageDomains: FC,
|
ManageDomains: FC,
|
||||||
) => withSelectedServer(({ location, selectedServer }) => {
|
) => withSelectedServer<MenuLayoutProps>(({ selectedServer, sidebarNotPresent, sidebarPresent }) => {
|
||||||
|
const location = useLocation();
|
||||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
||||||
|
const showContent = isReachableServer(selectedServer);
|
||||||
|
|
||||||
useEffect(() => hideSidebar(), [ location ]);
|
useEffect(() => hideSidebar(), [ location ]);
|
||||||
|
useEffect(() => {
|
||||||
|
showContent && sidebarPresent();
|
||||||
|
|
||||||
if (!isReachableServer(selectedServer)) {
|
return () => sidebarNotPresent();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!showContent) {
|
||||||
return <ServerError />;
|
return <ServerError />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
|
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
|
||||||
|
const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer);
|
||||||
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
|
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
|
||||||
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
||||||
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
||||||
@@ -46,21 +60,23 @@ const MenuLayout = (
|
|||||||
<AsideMenu selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
<AsideMenu selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
||||||
<div className="menu-layout__container" onClick={() => hideSidebar()}>
|
<div className="menu-layout__container" onClick={() => hideSidebar()}>
|
||||||
<div className="container-xl">
|
<div className="container-xl">
|
||||||
<Switch>
|
<Routes>
|
||||||
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
|
<Route index element={<Navigate replace to="overview" />} />
|
||||||
<Route exact path="/server/:serverId/overview" component={Overview} />
|
<Route path="/overview" element={<Overview />} />
|
||||||
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrlsList} />
|
<Route path="/list-short-urls/:page" element={<ShortUrlsList />} />
|
||||||
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
<Route path="/create-short-url" element={<CreateShortUrl />} />
|
||||||
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
<Route path="/short-code/:shortCode/visits/*" element={<ShortUrlVisits />} />
|
||||||
<Route path="/server/:serverId/short-code/:shortCode/edit" component={EditShortUrl} />
|
<Route path="/short-code/:shortCode/edit" element={<EditShortUrl />} />
|
||||||
<Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />
|
<Route path="/tag/:tag/visits/*" element={<TagVisits />} />
|
||||||
{addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />}
|
{addOrphanVisitsRoute && <Route path="/orphan-visits/*" element={<OrphanVisits />} />}
|
||||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
{addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />}
|
||||||
{addManageDomainsRoute && <Route exact path="/server/:serverId/manage-domains" component={ManageDomains} />}
|
<Route path="/manage-tags" element={<TagsList />} />
|
||||||
|
{addManageDomainsRoute && <Route path="/manage-domains" element={<ManageDomains />} />}
|
||||||
<Route
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { PropsWithChildren, useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { RouteComponentProps } from 'react-router';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
const ScrollToTop = (): FC => ({ children }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const ScrollToTop = () => ({ location, children }: PropsWithChildren<RouteComponentProps>) => {
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollTo(0, 0);
|
scrollTo(0, 0);
|
||||||
}, [ location ]);
|
}, [ location ]);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { pipe } from 'ramda';
|
import { pipe } from 'ramda';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
||||||
import { isReachableServer } from '../servers/data';
|
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||||
import { ShlinkVersionsContainerProps } from './ShlinkVersionsContainer';
|
|
||||||
|
|
||||||
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
||||||
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
|
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
|
||||||
|
|
||||||
export interface ShlinkVersionsProps extends ShlinkVersionsContainerProps {
|
export interface ShlinkVersionsProps {
|
||||||
|
selectedServer: SelectedServer;
|
||||||
clientVersion?: string;
|
clientVersion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@import '../utils/base';
|
@import '../utils/base';
|
||||||
|
|
||||||
.shlink-versions-container--with-server {
|
.shlink-versions-container--with-sidebar {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
@media (min-width: $mdMin) {
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import ShlinkVersions from './ShlinkVersions';
|
import ShlinkVersions from './ShlinkVersions';
|
||||||
|
import { Sidebar } from './reducers/sidebar';
|
||||||
import './ShlinkVersionsContainer.scss';
|
import './ShlinkVersionsContainer.scss';
|
||||||
|
|
||||||
export interface ShlinkVersionsContainerProps {
|
export interface ShlinkVersionsContainerProps {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
|
sidebar: Sidebar;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => {
|
const ShlinkVersionsContainer = ({ selectedServer, sidebar }: ShlinkVersionsContainerProps) => {
|
||||||
const classes = classNames('text-center', {
|
const classes = classNames('text-center', {
|
||||||
'shlink-versions-container--with-server': isReachableServer(selectedServer),
|
'shlink-versions-container--with-sidebar': sidebar.sidebarPresent,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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 axios from 'axios';
|
||||||
import Bottle, { Decorator } from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import ScrollToTop from '../ScrollToTop';
|
import ScrollToTop from '../ScrollToTop';
|
||||||
import MainHeader from '../MainHeader';
|
import MainHeader from '../MainHeader';
|
||||||
import Home from '../Home';
|
import Home from '../Home';
|
||||||
@@ -9,26 +9,26 @@ import ErrorHandler from '../ErrorHandler';
|
|||||||
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
|
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||||
|
import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar';
|
||||||
import { ImageDownloader } from './ImageDownloader';
|
import { ImageDownloader } from './ImageDownloader';
|
||||||
|
import { ReportExporter } from './ReportExporter';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Services
|
// Services
|
||||||
bottle.constant('window', (global as any).window);
|
bottle.constant('window', (global as any).window);
|
||||||
bottle.constant('console', global.console);
|
bottle.constant('console', global.console);
|
||||||
bottle.constant('axios', axios);
|
bottle.constant('axios', axios);
|
||||||
|
|
||||||
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
|
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
|
||||||
|
bottle.service('ReportExporter', ReportExporter, 'window', 'csvjson');
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('ScrollToTop', ScrollToTop);
|
bottle.serviceFactory('ScrollToTop', ScrollToTop);
|
||||||
bottle.decorator('ScrollToTop', withRouter);
|
|
||||||
|
|
||||||
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
|
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
|
||||||
bottle.decorator('MainHeader', withRouter);
|
|
||||||
|
|
||||||
bottle.serviceFactory('Home', () => Home);
|
bottle.serviceFactory('Home', () => Home);
|
||||||
bottle.decorator('Home', withoutSelectedServer);
|
bottle.decorator('Home', withoutSelectedServer);
|
||||||
bottle.decorator('Home', withRouter);
|
|
||||||
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
|
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory(
|
bottle.serviceFactory(
|
||||||
@@ -41,20 +41,24 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
|||||||
'ShortUrlVisits',
|
'ShortUrlVisits',
|
||||||
'TagVisits',
|
'TagVisits',
|
||||||
'OrphanVisits',
|
'OrphanVisits',
|
||||||
|
'NonOrphanVisits',
|
||||||
'ServerError',
|
'ServerError',
|
||||||
'Overview',
|
'Overview',
|
||||||
'EditShortUrl',
|
'EditShortUrl',
|
||||||
'ManageDomains',
|
'ManageDomains',
|
||||||
);
|
);
|
||||||
bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer' ]));
|
bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer', 'sidebarPresent', 'sidebarNotPresent' ]));
|
||||||
bottle.decorator('MenuLayout', withRouter);
|
|
||||||
|
|
||||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||||
|
|
||||||
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
|
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
|
||||||
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer' ]));
|
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer', 'sidebar' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('sidebarPresent', () => sidebarPresent);
|
||||||
|
bottle.serviceFactory('sidebarNotPresent', () => sidebarNotPresent);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default provideServices;
|
export default provideServices;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Bottle, { IContainer } from 'bottlejs';
|
import Bottle, { IContainer } from 'bottlejs';
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
import { connect as reduxConnect } from 'react-redux';
|
import { connect as reduxConnect } from 'react-redux';
|
||||||
import { pick } from 'ramda';
|
import { pick } from 'ramda';
|
||||||
import provideApiServices from '../api/services/provideServices';
|
import provideApiServices from '../api/services/provideServices';
|
||||||
@@ -34,11 +33,11 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
|
|||||||
actionServiceNames.reduce(mapActionService, {}),
|
actionServiceNames.reduce(mapActionService, {}),
|
||||||
);
|
);
|
||||||
|
|
||||||
provideAppServices(bottle, connect, withRouter);
|
provideAppServices(bottle, connect);
|
||||||
provideCommonServices(bottle, connect, withRouter);
|
provideCommonServices(bottle, connect);
|
||||||
provideApiServices(bottle);
|
provideApiServices(bottle);
|
||||||
provideShortUrlsServices(bottle, connect, withRouter);
|
provideShortUrlsServices(bottle, connect);
|
||||||
provideServersServices(bottle, connect, withRouter);
|
provideServersServices(bottle, connect);
|
||||||
provideTagsServices(bottle, connect);
|
provideTagsServices(bottle, connect);
|
||||||
provideVisitsServices(bottle, connect);
|
provideVisitsServices(bottle, connect);
|
||||||
provideUtilsServices(bottle);
|
provideUtilsServices(bottle);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { TagVisits } from '../visits/reducers/tagVisits';
|
|||||||
import { DomainsList } from '../domains/reducers/domainsList';
|
import { DomainsList } from '../domains/reducers/domainsList';
|
||||||
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||||
import { VisitsInfo } from '../visits/types';
|
import { VisitsInfo } from '../visits/types';
|
||||||
|
import { Sidebar } from '../common/reducers/sidebar';
|
||||||
|
|
||||||
export interface ShlinkState {
|
export interface ShlinkState {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
@@ -25,6 +26,7 @@ export interface ShlinkState {
|
|||||||
shortUrlVisits: ShortUrlVisits;
|
shortUrlVisits: ShortUrlVisits;
|
||||||
tagVisits: TagVisits;
|
tagVisits: TagVisits;
|
||||||
orphanVisits: VisitsInfo;
|
orphanVisits: VisitsInfo;
|
||||||
|
nonOrphanVisits: VisitsInfo;
|
||||||
shortUrlDetail: ShortUrlDetail;
|
shortUrlDetail: ShortUrlDetail;
|
||||||
tagsList: TagsList;
|
tagsList: TagsList;
|
||||||
tagDelete: TagDeletion;
|
tagDelete: TagDeletion;
|
||||||
@@ -34,6 +36,7 @@ export interface ShlinkState {
|
|||||||
domainsList: DomainsList;
|
domainsList: DomainsList;
|
||||||
visitsOverview: VisitsOverview;
|
visitsOverview: VisitsOverview;
|
||||||
appUpdated: boolean;
|
appUpdated: boolean;
|
||||||
|
sidebar: Sidebar;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const DomainRow: FC<DomainRowProps> = (
|
|||||||
<td className="responsive-table__cell text-lg-center" data-th="Status">
|
<td className="responsive-table__cell text-lg-center" data-th="Status">
|
||||||
<DomainStatusIcon status={status} />
|
<DomainStatusIcon status={status} />
|
||||||
</td>
|
</td>
|
||||||
<td className="responsive-table__cell text-right">
|
<td className="responsive-table__cell text-end">
|
||||||
<span id={!canEditDomain ? 'defaultDomainBtn' : undefined}>
|
<span id={!canEditDomain ? 'defaultDomainBtn' : undefined}>
|
||||||
<Button outline size="sm" disabled={!canEditDomain} onClick={!canEditDomain ? undefined : toggle}>
|
<Button outline size="sm" disabled={!canEditDomain} onClick={!canEditDomain ? undefined : toggle}>
|
||||||
<FontAwesomeIcon fixedWidth icon={!canEditDomain ? forbiddenIcon : editIcon} />
|
<FontAwesomeIcon fixedWidth icon={!canEditDomain ? forbiddenIcon : editIcon} />
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Button, DropdownItem, Input, InputGroup, InputGroupAddon, UncontrolledTooltip } from 'reactstrap';
|
import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip, InputProps } from 'reactstrap';
|
||||||
import { InputProps } from 'reactstrap/lib/Input';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faUndo } from '@fortawesome/free-solid-svg-icons';
|
import { faUndo } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { isEmpty, pipe } from 'ramda';
|
import { isEmpty, pipe } from 'ramda';
|
||||||
@@ -32,11 +31,10 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
|
|||||||
return inputDisplayed ? (
|
return inputDisplayed ? (
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<Input
|
<Input
|
||||||
value={value}
|
value={value ?? ''}
|
||||||
placeholder="Domain"
|
placeholder="Domain"
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<InputGroupAddon addonType="append">
|
|
||||||
<Button
|
<Button
|
||||||
id="backToDropdown"
|
id="backToDropdown"
|
||||||
outline
|
outline
|
||||||
@@ -49,7 +47,6 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
|
|||||||
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
|
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
|
||||||
Existing domains
|
Existing domains
|
||||||
</UncontrolledTooltip>
|
</UncontrolledTooltip>
|
||||||
</InputGroupAddon>
|
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
) : (
|
) : (
|
||||||
<DropdownBtn
|
<DropdownBtn
|
||||||
@@ -63,7 +60,7 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
|
|||||||
onClick={() => onChange(domain)}
|
onClick={() => onChange(domain)}
|
||||||
>
|
>
|
||||||
{domain}
|
{domain}
|
||||||
{isDefault && <span className="float-right text-muted">default</span>}
|
{isDefault && <span className="float-end text-muted">default</span>}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
))}
|
))}
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const ManageDomains: FC<ManageDomainsProps> = (
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SimpleCard>
|
<SimpleCard>
|
||||||
<table className="table table-hover mb-0">
|
<table className="table table-hover responsive-table mb-0">
|
||||||
<thead className="responsive-table__header">
|
<thead className="responsive-table__header">
|
||||||
<tr>{headers.map((column, index) => <th key={index}>{column}</th>)}</tr>
|
<tr>{headers.map((column, index) => <th key={index}>{column}</th>)}</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FC, useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import { ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
|
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 { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
|
||||||
import { InfoTooltip } from '../../utils/InfoTooltip';
|
import { InfoTooltip } from '../../utils/InfoTooltip';
|
||||||
|
|
||||||
@@ -12,8 +12,8 @@ interface EditDomainRedirectsModalProps {
|
|||||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormGroup: FC<FormGroupContainerProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
|
const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
|
||||||
<FormGroupContainer
|
<InputFormGroup
|
||||||
{...rest}
|
{...rest}
|
||||||
required={false}
|
required={false}
|
||||||
type="url"
|
type="url"
|
||||||
@@ -42,20 +42,20 @@ export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
|
|||||||
<ModalHeader toggle={toggle}>Edit redirects for <b>{domain.domain}</b></ModalHeader>
|
<ModalHeader toggle={toggle}>Edit redirects for <b>{domain.domain}</b></ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<FormGroup value={baseUrlRedirect} onChange={setBaseUrlRedirect}>
|
<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.
|
Visitors accessing the base url, as in <b>https://{domain.domain}/</b>, will be redirected to this URL.
|
||||||
</InfoTooltip>
|
</InfoTooltip>
|
||||||
Base URL
|
Base URL
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup value={regular404Redirect} onChange={setRegular404Redirect}>
|
<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>,
|
Visitors accessing a url not matching a short URL pattern, as in <b>https://{domain.domain}/???/[...]</b>,
|
||||||
will be redirected to this URL.
|
will be redirected to this URL.
|
||||||
</InfoTooltip>
|
</InfoTooltip>
|
||||||
Regular 404
|
Regular 404
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup value={invalidShortUrlRedirect} isLast onChange={setInvalidShortUrlRedirect}>
|
<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
|
Visitors accessing a url matching a short URL pattern, but not matching an existing short code, will be
|
||||||
redirected to this URL.
|
redirected to this URL.
|
||||||
</InfoTooltip>
|
</InfoTooltip>
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
outline: none !important;
|
outline: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
@@ -19,6 +23,17 @@ body,
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
.btn-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* stylelint-disable-next-line selector-max-pseudo-class */
|
||||||
|
a:not(.nav-link):not(.navbar-brand):not(.page-link):not(.highlight-card):not(.btn):not(.dropdown-item):hover,
|
||||||
|
.btn-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.bg-main {
|
.bg-main {
|
||||||
background-color: $mainColor !important;
|
background-color: $mainColor !important;
|
||||||
}
|
}
|
||||||
@@ -74,7 +89,8 @@ hr {
|
|||||||
border-color: var(--table-border-color);
|
border-color: var(--table-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-link:hover {
|
.page-link:hover,
|
||||||
|
.page-link:focus {
|
||||||
background-color: var(--secondary-color);
|
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,
|
||||||
.dropdown-item-text {
|
.dropdown-item-text {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@@ -133,10 +165,15 @@ hr {
|
|||||||
.close,
|
.close,
|
||||||
.close:hover,
|
.close:hover,
|
||||||
.table,
|
.table,
|
||||||
.table-hover tbody tr:hover {
|
.table-hover > tbody > tr:hover > *,
|
||||||
|
.table-hover > tbody > tr > * {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
filter: var(--btn-close-filter);
|
||||||
|
}
|
||||||
|
|
||||||
.table-hover tbody tr:hover {
|
.table-hover tbody tr:hover {
|
||||||
background-color: var(--secondary-color);
|
background-color: var(--secondary-color);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FC, useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { pipe } from 'ramda';
|
import { pipe } from 'ramda';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
import { CreateVisit } from '../../visits/types';
|
import { CreateVisit } from '../../visits/types';
|
||||||
import { MercureInfo } from '../reducers/mercureInfo';
|
import { MercureInfo } from '../reducers/mercureInfo';
|
||||||
import { bindToMercureTopic } from './index';
|
import { bindToMercureTopic } from './index';
|
||||||
@@ -12,17 +13,19 @@ export interface MercureBoundProps {
|
|||||||
|
|
||||||
export function boundToMercureHub<T = {}>(
|
export function boundToMercureHub<T = {}>(
|
||||||
WrappedComponent: FC<MercureBoundProps & T>,
|
WrappedComponent: FC<MercureBoundProps & T>,
|
||||||
getTopicsForProps: (props: T) => string[],
|
getTopicsForProps: (props: T, routeParams: any) => string[],
|
||||||
) {
|
) {
|
||||||
const pendingUpdates = new Set<CreateVisit>();
|
const pendingUpdates = new Set<CreateVisit>();
|
||||||
|
|
||||||
return (props: MercureBoundProps & T) => {
|
return (props: MercureBoundProps & T) => {
|
||||||
const { createNewVisits, loadMercureInfo, mercureInfo } = props;
|
const { createNewVisits, loadMercureInfo, mercureInfo } = props;
|
||||||
const { interval } = mercureInfo;
|
const { interval } = mercureInfo;
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]);
|
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) {
|
if (!interval) {
|
||||||
return closeEventSource;
|
return closeEventSource;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
|||||||
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
||||||
import tagVisitsReducer from '../visits/reducers/tagVisits';
|
import tagVisitsReducer from '../visits/reducers/tagVisits';
|
||||||
import orphanVisitsReducer from '../visits/reducers/orphanVisits';
|
import orphanVisitsReducer from '../visits/reducers/orphanVisits';
|
||||||
|
import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits';
|
||||||
import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail';
|
import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail';
|
||||||
import tagsListReducer from '../tags/reducers/tagsList';
|
import tagsListReducer from '../tags/reducers/tagsList';
|
||||||
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
||||||
@@ -17,6 +18,7 @@ import settingsReducer from '../settings/reducers/settings';
|
|||||||
import domainsListReducer from '../domains/reducers/domainsList';
|
import domainsListReducer from '../domains/reducers/domainsList';
|
||||||
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
|
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
|
||||||
import appUpdatesReducer from '../app/reducers/appUpdates';
|
import appUpdatesReducer from '../app/reducers/appUpdates';
|
||||||
|
import sidebarReducer from '../common/reducers/sidebar';
|
||||||
import { ShlinkState } from '../container/types';
|
import { ShlinkState } from '../container/types';
|
||||||
|
|
||||||
export default combineReducers<ShlinkState>({
|
export default combineReducers<ShlinkState>({
|
||||||
@@ -29,6 +31,7 @@ export default combineReducers<ShlinkState>({
|
|||||||
shortUrlVisits: shortUrlVisitsReducer,
|
shortUrlVisits: shortUrlVisitsReducer,
|
||||||
tagVisits: tagVisitsReducer,
|
tagVisits: tagVisitsReducer,
|
||||||
orphanVisits: orphanVisitsReducer,
|
orphanVisits: orphanVisitsReducer,
|
||||||
|
nonOrphanVisits: nonOrphanVisitsReducer,
|
||||||
shortUrlDetail: shortUrlDetailReducer,
|
shortUrlDetail: shortUrlDetailReducer,
|
||||||
tagsList: tagsListReducer,
|
tagsList: tagsListReducer,
|
||||||
tagDelete: tagDeleteReducer,
|
tagDelete: tagDeleteReducer,
|
||||||
@@ -38,4 +41,5 @@ export default combineReducers<ShlinkState>({
|
|||||||
domainsList: domainsListReducer,
|
domainsList: domainsListReducer,
|
||||||
visitsOverview: visitsOverviewReducer,
|
visitsOverview: visitsOverviewReducer,
|
||||||
appUpdated: appUpdatesReducer,
|
appUpdated: appUpdatesReducer,
|
||||||
|
sidebar: sidebarReducer,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { RouterProps } from 'react-router';
|
|
||||||
import { Button } from 'reactstrap';
|
import { Button } from 'reactstrap';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import { StateFlagTimeout, useToggle } from '../utils/helpers/hooks';
|
import { StateFlagTimeout, useGoBack, useToggle } from '../utils/helpers/hooks';
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||||
import { ServerData, ServersMap, ServerWithId } from './data';
|
import { ServerData, ServersMap, ServerWithId } from './data';
|
||||||
@@ -12,7 +12,7 @@ import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
|||||||
|
|
||||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||||
|
|
||||||
interface CreateServerProps extends RouterProps {
|
interface CreateServerProps {
|
||||||
createServer: (server: ServerWithId) => void;
|
createServer: (server: ServerWithId) => void;
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
}
|
}
|
||||||
@@ -27,8 +27,10 @@ const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
|
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 hasServers = !!Object.keys(servers).length;
|
||||||
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||||
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||||
@@ -42,7 +44,7 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
|
|||||||
const id = uuid();
|
const id = uuid();
|
||||||
|
|
||||||
createServer({ ...serverData, id });
|
createServer({ ...serverData, id });
|
||||||
push(`/server/${id}`);
|
navigate(`/server/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,7 +61,7 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
|
|||||||
{!hasServers &&
|
{!hasServers &&
|
||||||
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />}
|
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />}
|
||||||
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
|
{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>
|
</ServerForm>
|
||||||
|
|
||||||
{serversImported && <ImportResult type="success" />}
|
{serversImported && <ImportResult type="success" />}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import { RouterProps } from 'react-router';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ServerWithId } from './data';
|
import { ServerWithId } from './data';
|
||||||
|
|
||||||
export interface DeleteServerModalProps {
|
export interface DeleteServerModalProps {
|
||||||
@@ -10,17 +10,18 @@ export interface DeleteServerModalProps {
|
|||||||
redirectHome?: boolean;
|
redirectHome?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps {
|
interface DeleteServerModalConnectProps extends DeleteServerModalProps {
|
||||||
deleteServer: (server: ServerWithId) => void;
|
deleteServer: (server: ServerWithId) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
||||||
{ server, toggle, isOpen, deleteServer, history, redirectHome = true },
|
{ server, toggle, isOpen, deleteServer, redirectHome = true },
|
||||||
) => {
|
) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
deleteServer(server);
|
deleteServer(server);
|
||||||
toggle();
|
toggle();
|
||||||
redirectHome && history.push('/');
|
redirectHome && navigate('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { Button } from 'reactstrap';
|
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 { ServerForm } from './helpers/ServerForm';
|
||||||
import { withSelectedServer } from './helpers/withSelectedServer';
|
import { withSelectedServer } from './helpers/withSelectedServer';
|
||||||
import { isServerWithId, ServerData } from './data';
|
import { isServerWithId, ServerData } from './data';
|
||||||
@@ -9,9 +10,9 @@ interface EditServerProps {
|
|||||||
editServer: (serverId: string, serverData: ServerData) => void;
|
editServer: (serverId: string, serverData: ServerData) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
|
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>(({ editServer, selectedServer }) => {
|
||||||
{ editServer, selectedServer, history: { goBack } },
|
const goBack = useGoBack();
|
||||||
) => {
|
|
||||||
if (!isServerWithId(selectedServer)) {
|
if (!isServerWithId(selectedServer)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -28,7 +29,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
|
|||||||
initialValues={selectedServer}
|
initialValues={selectedServer}
|
||||||
onSubmit={handleSubmit}
|
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>
|
<Button outline color="primary">Save</Button>
|
||||||
</ServerForm>
|
</ServerForm>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
|
|||||||
@@ -45,12 +45,12 @@ export const ManageServers = (
|
|||||||
<div className="col-md-6 d-flex d-md-block mb-2 mb-md-0">
|
<div className="col-md-6 d-flex d-md-block mb-2 mb-md-0">
|
||||||
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
|
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
|
||||||
{allServers.length > 0 && (
|
{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
|
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<Button outline color="primary" className="flex-fill" tag={Link} to="/server/create">
|
||||||
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
|
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
|
||||||
</Button>
|
</Button>
|
||||||
@@ -58,7 +58,7 @@ export const ManageServers = (
|
|||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<SimpleCard>
|
<SimpleCard>
|
||||||
<table className="table table-hover mb-0">
|
<table className="table table-hover responsive-table mb-0">
|
||||||
<thead className="responsive-table__header">
|
<thead className="responsive-table__header">
|
||||||
<tr>
|
<tr>
|
||||||
{hasAutoConnect && <th style={{ width: '50px' }} />}
|
{hasAutoConnect && <th style={{ width: '50px' }} />}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const ManageServersRow = (
|
|||||||
<Link to={`/server/${server.id}`}>{server.name}</Link>
|
<Link to={`/server/${server.id}`}>{server.name}</Link>
|
||||||
</th>
|
</th>
|
||||||
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
|
<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} />
|
<ManageServersRowDropdown server={server} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 { FC, useEffect } from 'react';
|
||||||
import { Card, CardBody, CardHeader, CardText, CardTitle, Row } from 'reactstrap';
|
import { Card, CardBody, CardHeader, Row } from 'reactstrap';
|
||||||
import { Link, useHistory } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
import { ITEMS_IN_OVERVIEW_PAGE, ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
||||||
import { prettify } from '../utils/helpers/numbers';
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
import { TagsList } from '../tags/reducers/tagsList';
|
import { TagsList } from '../tags/reducers/tagsList';
|
||||||
import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
|
import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
|
||||||
@@ -11,8 +11,9 @@ import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
|||||||
import { Versions } from '../utils/helpers/version';
|
import { Versions } from '../utils/helpers/version';
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
import { ShlinkShortUrlsListParams } from '../api/types';
|
import { ShlinkShortUrlsListParams } from '../api/types';
|
||||||
|
import { supportsNonOrphanVisits, supportsOrphanVisits } from '../utils/helpers/features';
|
||||||
import { getServerId, SelectedServer } from './data';
|
import { getServerId, SelectedServer } from './data';
|
||||||
import './Overview.scss';
|
import { HighlightCard } from './helpers/HighlightCard';
|
||||||
|
|
||||||
interface OverviewConnectProps {
|
interface OverviewConnectProps {
|
||||||
shortUrlsList: ShortUrlsListState;
|
shortUrlsList: ShortUrlsListState;
|
||||||
@@ -41,10 +42,12 @@ export const Overview = (
|
|||||||
const { loading: loadingTags } = tagsList;
|
const { loading: loadingTags } = tagsList;
|
||||||
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
|
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
|
||||||
const serverId = getServerId(selectedServer);
|
const serverId = getServerId(selectedServer);
|
||||||
const history = useHistory();
|
const linkToOrphanVisits = supportsOrphanVisits(selectedServer);
|
||||||
|
const linkToNonOrphanVisits = supportsNonOrphanVisits(selectedServer);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listShortUrls({ itemsPerPage: 5, orderBy: { field: 'dateCreated', dir: 'DESC' } });
|
listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } });
|
||||||
listTags();
|
listTags();
|
||||||
loadVisitsOverview();
|
loadVisitsOverview();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -52,45 +55,38 @@ export const Overview = (
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Row>
|
<Row>
|
||||||
<div className="col-md-6 col-xl-3">
|
<div className="col-lg-6 col-xl-3 mb-3">
|
||||||
<Card className="overview__card mb-3" body>
|
<HighlightCard title="Visits" link={linkToNonOrphanVisits && `/server/${serverId}/non-orphan-visits`}>
|
||||||
<CardTitle tag="h5" className="overview__card-title">Visits</CardTitle>
|
{loadingVisits ? 'Loading...' : prettify(visitsCount)}
|
||||||
<CardText tag="h2">{loadingVisits ? 'Loading...' : prettify(visitsCount)}</CardText>
|
</HighlightCard>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6 col-xl-3">
|
<div className="col-lg-6 col-xl-3 mb-3">
|
||||||
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/orphan-visits`}>
|
<HighlightCard title="Orphan visits" link={linkToOrphanVisits && `/server/${serverId}/orphan-visits`}>
|
||||||
<CardTitle tag="h5" className="overview__card-title">Orphan visits</CardTitle>
|
|
||||||
<CardText tag="h2">
|
|
||||||
<ForServerVersion minVersion="2.6.0">
|
<ForServerVersion minVersion="2.6.0">
|
||||||
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)}
|
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)}
|
||||||
</ForServerVersion>
|
</ForServerVersion>
|
||||||
<ForServerVersion maxVersion="2.5.*">
|
<ForServerVersion maxVersion="2.5.*">
|
||||||
<small className="text-muted"><i>Shlink 2.6 is needed</i></small>
|
<small className="text-muted"><i>Shlink 2.6 is needed</i></small>
|
||||||
</ForServerVersion>
|
</ForServerVersion>
|
||||||
</CardText>
|
</HighlightCard>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6 col-xl-3">
|
<div className="col-lg-6 col-xl-3 mb-3">
|
||||||
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/list-short-urls/1`}>
|
<HighlightCard title="Short URLs" link={`/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)}
|
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
|
||||||
</CardText>
|
</HighlightCard>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6 col-xl-3">
|
<div className="col-lg-6 col-xl-3 mb-3">
|
||||||
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/manage-tags`}>
|
<HighlightCard title="Tags" link={`/server/${serverId}/manage-tags`}>
|
||||||
<CardTitle tag="h5" className="overview__card-title">Tags</CardTitle>
|
{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}
|
||||||
<CardText tag="h2">{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}</CardText>
|
</HighlightCard>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Card className="mb-3">
|
<Card className="mb-3">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<span className="d-sm-none">Create a short URL</span>
|
<span className="d-sm-none">Create a short URL</span>
|
||||||
<h5 className="d-none d-sm-inline">Create a short URL</h5>
|
<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>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<CreateShortUrl basicMode />
|
<CreateShortUrl basicMode />
|
||||||
@@ -100,14 +96,14 @@ export const Overview = (
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<span className="d-sm-none">Recently created URLs</span>
|
<span className="d-sm-none">Recently created URLs</span>
|
||||||
<h5 className="d-none d-sm-inline">Recently created URLs</h5>
|
<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>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<ShortUrlsTable
|
<ShortUrlsTable
|
||||||
shortUrlsList={shortUrlsList}
|
shortUrlsList={shortUrlsList}
|
||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
className="mb-0"
|
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>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
|
|||||||
if (isEmpty(serversList)) {
|
if (isEmpty(serversList)) {
|
||||||
return (
|
return (
|
||||||
<DropdownItem tag={Link} to="/server/create">
|
<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>
|
</DropdownItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@ const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
|
|||||||
))}
|
))}
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
<DropdownItem tag={Link} to="/manage-servers">
|
<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>
|
</DropdownItem>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -40,9 +40,9 @@ const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
|
|||||||
return (
|
return (
|
||||||
<UncontrolledDropdown nav inNavbar>
|
<UncontrolledDropdown nav inNavbar>
|
||||||
<DropdownToggle nav caret>
|
<DropdownToggle nav caret>
|
||||||
<FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Servers</span>
|
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Servers</span>
|
||||||
</DropdownToggle>
|
</DropdownToggle>
|
||||||
<DropdownMenu right>{renderServers()}</DropdownMenu>
|
<DropdownMenu end style={{ right: 0 }}>{renderServers()}</DropdownMenu>
|
||||||
</UncontrolledDropdown>
|
</UncontrolledDropdown>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
padding: .75rem 2.5rem .75rem 1rem;
|
padding: .75rem 2.5rem .75rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.servers-list__server-item:not(:hover) {
|
||||||
|
color: $mainColor;
|
||||||
|
}
|
||||||
|
|
||||||
.servers-list__server-item:hover {
|
.servers-list__server-item:hover {
|
||||||
background-color: var(--secondary-color);
|
background-color: var(--secondary-color);
|
||||||
}
|
}
|
||||||
|
|||||||
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 +0,0 @@
|
|||||||
@import '../../utils/base';
|
|
||||||
|
|
||||||
.server-form .form-group:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-form__label {
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { FC, ReactNode, useEffect, useState } from 'react';
|
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 { handleEventPreventingDefault } from '../../utils/utils';
|
||||||
import { ServerData } from '../data';
|
import { ServerData } from '../data';
|
||||||
import { SimpleCard } from '../../utils/SimpleCard';
|
import { SimpleCard } from '../../utils/SimpleCard';
|
||||||
import './ServerForm.scss';
|
|
||||||
|
|
||||||
interface ServerFormProps {
|
interface ServerFormProps {
|
||||||
onSubmit: (server: ServerData) => void;
|
onSubmit: (server: ServerData) => void;
|
||||||
@@ -11,9 +10,6 @@ interface ServerFormProps {
|
|||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormGroup: FC<FormGroupContainerProps> = (props) =>
|
|
||||||
<FormGroupContainer {...props} labelClassName="server-form__label" />;
|
|
||||||
|
|
||||||
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
|
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
|
||||||
const [ name, setName ] = useState('');
|
const [ name, setName ] = useState('');
|
||||||
const [ url, setUrl ] = useState('');
|
const [ url, setUrl ] = useState('');
|
||||||
@@ -29,12 +25,12 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
|
|||||||
return (
|
return (
|
||||||
<form className="server-form" onSubmit={handleSubmit}>
|
<form className="server-form" onSubmit={handleSubmit}>
|
||||||
<SimpleCard className="mb-3" title={title}>
|
<SimpleCard className="mb-3" title={title}>
|
||||||
<FormGroup value={name} onChange={setName}>Name</FormGroup>
|
<InputFormGroup value={name} onChange={setName}>Name</InputFormGroup>
|
||||||
<FormGroup type="url" value={url} onChange={setUrl}>URL</FormGroup>
|
<InputFormGroup type="url" value={url} onChange={setUrl}>URL</InputFormGroup>
|
||||||
<FormGroup value={apiKey} onChange={setApiKey}>API key</FormGroup>
|
<InputFormGroup value={apiKey} onChange={setApiKey}>API key</InputFormGroup>
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
|
|
||||||
<div className="text-right">{children}</div>
|
<div className="text-end">{children}</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import { FC, useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { RouteComponentProps } from 'react-router';
|
import { useParams } from 'react-router-dom';
|
||||||
import Message from '../../utils/Message';
|
import Message from '../../utils/Message';
|
||||||
import { isNotFoundServer, SelectedServer } from '../data';
|
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;
|
selectServer: (serverId: string) => void;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServerProps & T>, ServerError: FC) {
|
export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServerProps & T>, ServerError: FC) {
|
||||||
return (props: WithSelectedServerProps & T) => {
|
return (props: WithSelectedServerProps & T) => {
|
||||||
const { selectServer, selectedServer, match } = props;
|
const params = useParams<{ serverId: string }>();
|
||||||
|
const { selectServer, selectedServer } = props;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectServer(match.params.serverId);
|
params.serverId && selectServer(params.serverId);
|
||||||
}, [ match.params.serverId ]);
|
}, [ params.serverId ]);
|
||||||
|
|
||||||
if (!selectedServer) {
|
if (!selectedServer) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import csvjson from 'csvjson';
|
import csvjson from 'csvjson';
|
||||||
import Bottle, { Decorator } from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import CreateServer from '../CreateServer';
|
import CreateServer from '../CreateServer';
|
||||||
import ServersDropdown from '../ServersDropdown';
|
import ServersDropdown from '../ServersDropdown';
|
||||||
import DeleteServerModal from '../DeleteServerModal';
|
import DeleteServerModal from '../DeleteServerModal';
|
||||||
@@ -20,7 +20,7 @@ import { ManageServersRowDropdown } from '../ManageServersRowDropdown';
|
|||||||
import { ServersImporter } from './ServersImporter';
|
import { ServersImporter } from './ServersImporter';
|
||||||
import ServersExporter from './ServersExporter';
|
import ServersExporter from './ServersExporter';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory(
|
bottle.serviceFactory(
|
||||||
'ManageServers',
|
'ManageServers',
|
||||||
@@ -30,7 +30,8 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
|||||||
'useStateFlagTimeout',
|
'useStateFlagTimeout',
|
||||||
'ManageServersRow',
|
'ManageServersRow',
|
||||||
);
|
);
|
||||||
bottle.decorator('ManageServers', connect([ 'servers' ]));
|
bottle.decorator('ManageServers', withoutSelectedServer);
|
||||||
|
bottle.decorator('ManageServers', connect([ 'selectedServer', 'servers' ], [ 'resetSelectedServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('ManageServersRow', ManageServersRow, 'ManageServersRowDropdown');
|
bottle.serviceFactory('ManageServersRow', ManageServersRow, 'ManageServersRowDropdown');
|
||||||
|
|
||||||
@@ -42,13 +43,12 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
|||||||
bottle.decorator('CreateServer', connect([ 'selectedServer', 'servers' ], [ 'createServer', 'resetSelectedServer' ]));
|
bottle.decorator('CreateServer', connect([ 'selectedServer', 'servers' ], [ 'createServer', 'resetSelectedServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
|
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
|
||||||
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
|
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer', 'resetSelectedServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
|
bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
|
||||||
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
|
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
||||||
bottle.decorator('DeleteServerModal', withRouter);
|
|
||||||
bottle.decorator('DeleteServerModal', connect(null, [ 'deleteServer' ]));
|
bottle.decorator('DeleteServerModal', connect(null, [ 'deleteServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');
|
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { FormGroup, Input } from 'reactstrap';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
import { FormText } from '../utils/forms/FormText';
|
||||||
|
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
|
||||||
import { Settings } from './reducers/settings';
|
import { Settings } from './reducers/settings';
|
||||||
|
|
||||||
interface RealTimeUpdatesProps {
|
interface RealTimeUpdatesProps {
|
||||||
@@ -19,15 +21,16 @@ const RealTimeUpdatesSettings = (
|
|||||||
<FormGroup>
|
<FormGroup>
|
||||||
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
|
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
|
||||||
Enable or disable real-time updates.
|
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>.
|
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
|
||||||
</small>
|
</FormText>
|
||||||
</ToggleSwitch>
|
</ToggleSwitch>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup className="mb-0">
|
<LabeledFormGroup
|
||||||
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
|
noMargin
|
||||||
Real-time updates frequency (in minutes):
|
label="Real-time updates frequency (in minutes):"
|
||||||
</label>
|
labelClassName={classNames('form-label', { 'text-muted': !realTimeUpdates.enabled })}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -37,16 +40,16 @@ const RealTimeUpdatesSettings = (
|
|||||||
onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))}
|
onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))}
|
||||||
/>
|
/>
|
||||||
{realTimeUpdates.enabled && (
|
{realTimeUpdates.enabled && (
|
||||||
<small className="form-text text-muted">
|
<FormText>
|
||||||
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
|
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
|
||||||
<span>
|
<span>
|
||||||
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
|
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
|
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
|
||||||
</small>
|
</FormText>
|
||||||
)}
|
)}
|
||||||
</FormGroup>
|
</LabeledFormGroup>
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
import { FC, ReactNode } from 'react';
|
import { FC, ReactNode } from 'react';
|
||||||
import { Row } from 'reactstrap';
|
import { Navigate, Routes, Route } from 'react-router-dom';
|
||||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
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) => (
|
{items.map((child, index) => <div key={index} className="mb-3">{child}</div>)}
|
||||||
<Row key={index}>
|
|
||||||
{child.map((subChild, subIndex) => (
|
|
||||||
<div key={subIndex} className={`col-lg-${12 / child.length} mb-3`}>
|
|
||||||
{subChild}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Row>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -25,13 +18,18 @@ const Settings = (
|
|||||||
Tags: FC,
|
Tags: FC,
|
||||||
) => () => (
|
) => () => (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<SettingsSections
|
<NavPills className="mb-3">
|
||||||
items={[
|
<NavPillItem to="general">General</NavPillItem>
|
||||||
[ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key
|
<NavPillItem to="short-urls">Short URLs</NavPillItem>
|
||||||
[ <ShortUrlCreation />, <ShortUrlsList /> ], // eslint-disable-line react/jsx-key
|
<NavPillItem to="other-items">Other items</NavPillItem>
|
||||||
[ <Tags />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
|
</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>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { DropdownItem, FormGroup } from 'reactstrap';
|
|||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||||
import { DropdownBtn } from '../utils/DropdownBtn';
|
import { DropdownBtn } from '../utils/DropdownBtn';
|
||||||
|
import { FormText } from '../utils/forms/FormText';
|
||||||
|
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
|
||||||
import { Settings, ShortUrlCreationSettings as ShortUrlsSettings, TagFilteringMode } from './reducers/settings';
|
import { Settings, ShortUrlCreationSettings as ShortUrlsSettings, TagFilteringMode } from './reducers/settings';
|
||||||
|
|
||||||
interface ShortUrlCreationProps {
|
interface ShortUrlCreationProps {
|
||||||
@@ -31,10 +33,10 @@ export const ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings,
|
|||||||
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
|
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
|
||||||
>
|
>
|
||||||
Request validation on long URLs when creating new short URLs.
|
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
|
The initial state of the <b>Validate URL</b> checkbox will
|
||||||
be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>.
|
be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>.
|
||||||
</small>
|
</FormText>
|
||||||
</ToggleSwitch>
|
</ToggleSwitch>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
@@ -43,14 +45,13 @@ export const ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings,
|
|||||||
onChange={(forwardQuery) => setShortUrlCreationSettings({ ...shortUrlCreation, forwardQuery })}
|
onChange={(forwardQuery) => setShortUrlCreationSettings({ ...shortUrlCreation, forwardQuery })}
|
||||||
>
|
>
|
||||||
Make all new short URLs forward their query params to the long URL.
|
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
|
The initial state of the <b>Forward query params on redirect</b> checkbox will
|
||||||
be <b>{shortUrlCreation.forwardQuery ?? true ? 'checked' : 'unchecked'}</b>.
|
be <b>{shortUrlCreation.forwardQuery ?? true ? 'checked' : 'unchecked'}</b>.
|
||||||
</small>
|
</FormText>
|
||||||
</ToggleSwitch>
|
</ToggleSwitch>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup className="mb-0">
|
<LabeledFormGroup noMargin label="Tag suggestions search mode:">
|
||||||
<label>Tag suggestions search mode:</label>
|
|
||||||
<DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}>
|
<DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
active={!shortUrlCreation.tagFilteringMode || shortUrlCreation.tagFilteringMode === 'startsWith'}
|
active={!shortUrlCreation.tagFilteringMode || shortUrlCreation.tagFilteringMode === 'startsWith'}
|
||||||
@@ -65,10 +66,8 @@ export const ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings,
|
|||||||
{tagFilteringModeText('includes')}
|
{tagFilteringModeText('includes')}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</DropdownBtn>
|
</DropdownBtn>
|
||||||
<small className="form-text text-muted">
|
<FormText>{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}</FormText>
|
||||||
{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}
|
</LabeledFormGroup>
|
||||||
</small>
|
|
||||||
</FormGroup>
|
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { FormGroup } from 'reactstrap';
|
|
||||||
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||||
import { SHORT_URLS_ORDERABLE_FIELDS } from '../short-urls/data';
|
import { SHORT_URLS_ORDERABLE_FIELDS } from '../short-urls/data';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
|
||||||
import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings as ShortUrlsSettings } from './reducers/settings';
|
import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings as ShortUrlsSettings } from './reducers/settings';
|
||||||
|
|
||||||
interface ShortUrlsListProps {
|
interface ShortUrlsListSettingsProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
setShortUrlsListSettings: (settings: ShortUrlsSettings) => void;
|
setShortUrlsListSettings: (settings: ShortUrlsSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShortUrlsListSettings: FC<ShortUrlsListProps> = (
|
export const ShortUrlsListSettings: FC<ShortUrlsListSettingsProps> = (
|
||||||
{ settings: { shortUrlsList }, setShortUrlsListSettings },
|
{ settings: { shortUrlsList }, setShortUrlsListSettings },
|
||||||
) => (
|
) => (
|
||||||
<SimpleCard title="Short URLs list" className="h-100">
|
<SimpleCard title="Short URLs list" className="h-100">
|
||||||
<FormGroup className="mb-0">
|
<LabeledFormGroup noMargin label="Default ordering for short URLs list:">
|
||||||
<label>Default ordering for short URLs list:</label>
|
|
||||||
<OrderingDropdown
|
<OrderingDropdown
|
||||||
items={SHORT_URLS_ORDERABLE_FIELDS}
|
items={SHORT_URLS_ORDERABLE_FIELDS}
|
||||||
order={shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING}
|
order={shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING}
|
||||||
onChange={(field, dir) => setShortUrlsListSettings({ defaultOrdering: { field, dir } })}
|
onChange={(field, dir) => setShortUrlsListSettings({ defaultOrdering: { field, dir } })}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</LabeledFormGroup>
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { FormGroup } from 'reactstrap';
|
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
|
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
|
||||||
import { capitalize } from '../utils/utils';
|
import { capitalize } from '../utils/utils';
|
||||||
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||||
import { TAGS_ORDERABLE_FIELDS } from '../tags/data/TagsListChildrenProps';
|
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';
|
import { Settings, TagsSettings as TagsSettingsOptions } from './reducers/settings';
|
||||||
|
|
||||||
interface TagsProps {
|
interface TagsProps {
|
||||||
@@ -14,22 +15,20 @@ interface TagsProps {
|
|||||||
|
|
||||||
export const TagsSettings: FC<TagsProps> = ({ settings: { tags }, setTagsSettings }) => (
|
export const TagsSettings: FC<TagsProps> = ({ settings: { tags }, setTagsSettings }) => (
|
||||||
<SimpleCard title="Tags" className="h-100">
|
<SimpleCard title="Tags" className="h-100">
|
||||||
<FormGroup>
|
<LabeledFormGroup label="Default display mode when managing tags:">
|
||||||
<label>Default display mode when managing tags:</label>
|
|
||||||
<TagsModeDropdown
|
<TagsModeDropdown
|
||||||
mode={tags?.defaultMode ?? 'cards'}
|
mode={tags?.defaultMode ?? 'cards'}
|
||||||
renderTitle={(tagsMode) => capitalize(tagsMode)}
|
renderTitle={(tagsMode) => capitalize(tagsMode)}
|
||||||
onChange={(defaultMode) => setTagsSettings({ ...tags, defaultMode })}
|
onChange={(defaultMode) => setTagsSettings({ ...tags, defaultMode })}
|
||||||
/>
|
/>
|
||||||
<small className="form-text text-muted">Tags will be displayed as <b>{tags?.defaultMode ?? 'cards'}</b>.</small>
|
<FormText>Tags will be displayed as <b>{tags?.defaultMode ?? 'cards'}</b>.</FormText>
|
||||||
</FormGroup>
|
</LabeledFormGroup>
|
||||||
<FormGroup className="mb-0">
|
<LabeledFormGroup noMargin label="Default ordering for tags list:">
|
||||||
<label>Default ordering for tags list:</label>
|
|
||||||
<OrderingDropdown
|
<OrderingDropdown
|
||||||
items={TAGS_ORDERABLE_FIELDS}
|
items={TAGS_ORDERABLE_FIELDS}
|
||||||
order={tags?.defaultOrdering ?? {}}
|
order={tags?.defaultOrdering ?? {}}
|
||||||
onChange={(field, dir) => setTagsSettings({ ...tags, defaultOrdering: { field, dir } })}
|
onChange={(field, dir) => setTagsSettings({ ...tags, defaultOrdering: { field, dir } })}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</LabeledFormGroup>
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
|
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FormGroup } from 'reactstrap';
|
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||||
import { changeThemeInMarkup, Theme } from '../utils/theme';
|
import { changeThemeInMarkup, Theme } from '../utils/theme';
|
||||||
@@ -15,7 +14,6 @@ interface UserInterfaceProps {
|
|||||||
|
|
||||||
export const UserInterfaceSettings: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
|
export const UserInterfaceSettings: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
|
||||||
<SimpleCard title="User interface" className="h-100">
|
<SimpleCard title="User interface" className="h-100">
|
||||||
<FormGroup>
|
|
||||||
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={ui?.theme === 'dark'}
|
checked={ui?.theme === 'dark'}
|
||||||
@@ -28,6 +26,5 @@ export const UserInterfaceSettings: FC<UserInterfaceProps> = ({ settings: { ui }
|
|||||||
>
|
>
|
||||||
Use dark theme.
|
Use dark theme.
|
||||||
</ToggleSwitch>
|
</ToggleSwitch>
|
||||||
</FormGroup>
|
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FormGroup } from 'reactstrap';
|
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
|
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
|
||||||
|
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
|
||||||
import { Settings, VisitsSettings as VisitsSettingsConfig } from './reducers/settings';
|
import { Settings, VisitsSettings as VisitsSettingsConfig } from './reducers/settings';
|
||||||
|
|
||||||
interface VisitsProps {
|
interface VisitsProps {
|
||||||
@@ -11,13 +11,12 @@ interface VisitsProps {
|
|||||||
|
|
||||||
export const VisitsSettings: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
|
export const VisitsSettings: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
|
||||||
<SimpleCard title="Visits" className="h-100">
|
<SimpleCard title="Visits" className="h-100">
|
||||||
<FormGroup className="mb-0">
|
<LabeledFormGroup noMargin label="Default interval to load on visits sections:">
|
||||||
<label>Default interval to load on visits sections:</label>
|
|
||||||
<DateIntervalSelector
|
<DateIntervalSelector
|
||||||
allText="All visits"
|
allText="All visits"
|
||||||
active={settings.visits?.defaultInterval ?? 'last30Days'}
|
active={settings.visits?.defaultInterval ?? 'last30Days'}
|
||||||
onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
|
onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</LabeledFormGroup>
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { FC, useEffect, useMemo } from 'react';
|
import { FC, useEffect, useMemo } from 'react';
|
||||||
import { RouteComponentProps } from 'react-router';
|
|
||||||
import { Button, Card } from 'reactstrap';
|
import { Button, Card } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
|
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
|
import { useLocation, useParams } from 'react-router-dom';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings';
|
import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings';
|
||||||
import { OptionalString } from '../utils/utils';
|
import { OptionalString } from '../utils/utils';
|
||||||
@@ -11,13 +11,13 @@ import { parseQuery } from '../utils/helpers/query';
|
|||||||
import Message from '../utils/Message';
|
import Message from '../utils/Message';
|
||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useGoBack, useToggle } from '../utils/helpers/hooks';
|
||||||
import { ShortUrlFormProps } from './ShortUrlForm';
|
import { ShortUrlFormProps } from './ShortUrlForm';
|
||||||
import { ShortUrlDetail } from './reducers/shortUrlDetail';
|
import { ShortUrlDetail } from './reducers/shortUrlDetail';
|
||||||
import { EditShortUrlData, ShortUrl, ShortUrlData } from './data';
|
import { EditShortUrlData, ShortUrl, ShortUrlData } from './data';
|
||||||
import { ShortUrlEdition } from './reducers/shortUrlEdition';
|
import { ShortUrlEdition } from './reducers/shortUrlEdition';
|
||||||
|
|
||||||
interface EditShortUrlConnectProps extends RouteComponentProps<{ shortCode: string }> {
|
interface EditShortUrlConnectProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
shortUrlDetail: ShortUrlDetail;
|
shortUrlDetail: ShortUrlDetail;
|
||||||
@@ -48,9 +48,6 @@ const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSetting
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
||||||
history: { goBack },
|
|
||||||
match: { params },
|
|
||||||
location: { search },
|
|
||||||
settings: { shortUrlCreation: shortUrlCreationSettings },
|
settings: { shortUrlCreation: shortUrlCreationSettings },
|
||||||
selectedServer,
|
selectedServer,
|
||||||
shortUrlDetail,
|
shortUrlDetail,
|
||||||
@@ -58,6 +55,9 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
|||||||
shortUrlEdition,
|
shortUrlEdition,
|
||||||
editShortUrl,
|
editShortUrl,
|
||||||
}: EditShortUrlConnectProps) => {
|
}: EditShortUrlConnectProps) => {
|
||||||
|
const { search } = useLocation();
|
||||||
|
const params = useParams<{ shortCode: string }>();
|
||||||
|
const goBack = useGoBack();
|
||||||
const { loading, error, errorData, shortUrl } = shortUrlDetail;
|
const { loading, error, errorData, shortUrl } = shortUrlDetail;
|
||||||
const { saving, error: savingError, errorData: savingErrorData } = shortUrlEdition;
|
const { saving, error: savingError, errorData: savingErrorData } = shortUrlEdition;
|
||||||
const { domain } = parseQuery<{ domain?: string }>(search);
|
const { domain } = parseQuery<{ domain?: string }>(search);
|
||||||
@@ -68,7 +68,7 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
|||||||
const [ savingSucceeded,, isSuccessful, isNotSuccessful ] = useToggle();
|
const [ savingSucceeded,, isSuccessful, isNotSuccessful ] = useToggle();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getShortUrlDetail(params.shortCode, domain);
|
params.shortCode && getShortUrlDetail(params.shortCode, domain);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -88,7 +88,7 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
|||||||
<header className="mb-3">
|
<header className="mb-3">
|
||||||
<Card body>
|
<Card body>
|
||||||
<h2 className="d-sm-flex justify-content-between align-items-center mb-0">
|
<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} />
|
<FontAwesomeIcon icon={faArrowLeft} />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-center">
|
<span className="text-center">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
@import '../utils/base';
|
@import '../utils/base';
|
||||||
|
|
||||||
.short-url-form .card-body > .form-group:last-child,
|
|
||||||
.short-url-form p:last-child {
|
.short-url-form p:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FC, useEffect, useState } from 'react';
|
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 { Button, FormGroup, Input, Row } from 'reactstrap';
|
||||||
import { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
|
import { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
@@ -86,7 +86,6 @@ export const ShortUrlForm = (
|
|||||||
</FormGroup>
|
</FormGroup>
|
||||||
);
|
);
|
||||||
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
|
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
|
||||||
<div className="form-group">
|
|
||||||
<DateInput
|
<DateInput
|
||||||
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
|
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
|
||||||
placeholderText={placeholder}
|
placeholderText={placeholder}
|
||||||
@@ -94,7 +93,6 @@ export const ShortUrlForm = (
|
|||||||
onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })}
|
onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
const basicComponents = (
|
const basicComponents = (
|
||||||
<>
|
<>
|
||||||
@@ -110,9 +108,9 @@ export const ShortUrlForm = (
|
|||||||
</FormGroup>
|
</FormGroup>
|
||||||
<Row>
|
<Row>
|
||||||
{isBasicMode && renderOptionalInput('customSlug', 'Custom slug', 'text', { bsSize: 'lg' }, { className: 'col-lg-6' })}
|
{isBasicMode && renderOptionalInput('customSlug', 'Custom slug', 'text', { bsSize: 'lg' }, { className: 'col-lg-6' })}
|
||||||
<FormGroup className={isBasicMode ? 'col-lg-6' : 'col-12 mb-0'}>
|
<div className={isBasicMode ? 'col-lg-6' : 'col-12 mb-0'}>
|
||||||
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
|
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
|
||||||
</FormGroup>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -154,12 +152,10 @@ export const ShortUrlForm = (
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
<FormGroup>
|
|
||||||
<DomainSelector
|
<DomainSelector
|
||||||
value={shortUrlData.domain}
|
value={shortUrlData.domain}
|
||||||
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
|
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
@@ -169,7 +165,9 @@ export const ShortUrlForm = (
|
|||||||
<div className={limitAccessCardClasses}>
|
<div className={limitAccessCardClasses}>
|
||||||
<SimpleCard title="Limit access to the short URL">
|
<SimpleCard title="Limit access to the short URL">
|
||||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||||
|
<div className="mb-3">
|
||||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? toDate(shortUrlData.validUntil) : undefined })}
|
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? toDate(shortUrlData.validUntil) : undefined })}
|
||||||
|
</div>
|
||||||
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? toDate(shortUrlData.validSince) : undefined })}
|
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? toDate(shortUrlData.validSince) : undefined })}
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,7 +187,7 @@ export const ShortUrlForm = (
|
|||||||
<p>
|
<p>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
inline
|
inline
|
||||||
className="mr-2"
|
className="me-2"
|
||||||
checked={shortUrlData.findIfExists}
|
checked={shortUrlData.findIfExists}
|
||||||
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
|
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,24 +1,41 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { isEmpty, pipe } from 'ramda';
|
import { isEmpty, pipe } from 'ramda';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
import { RouteChildrenProps } from 'react-router-dom';
|
import { Row } from 'reactstrap';
|
||||||
|
import classNames from 'classnames';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import Tag from '../tags/helpers/Tag';
|
import Tag from '../tags/helpers/Tag';
|
||||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||||
import { formatIsoDate } from '../utils/helpers/date';
|
import { formatIsoDate } from '../utils/helpers/date';
|
||||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||||
import { DateRange } from '../utils/dates/types';
|
import { DateRange } from '../utils/dates/types';
|
||||||
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
|
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';
|
import './ShortUrlsFilteringBar.scss';
|
||||||
|
|
||||||
export type ShortUrlsFilteringProps = RouteChildrenProps<ShortUrlListRouteParams>;
|
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 dateOrNull = (date?: string) => date ? parseISO(date) : null;
|
||||||
|
|
||||||
const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => (props: ShortUrlsFilteringProps) => {
|
const ShortUrlsFilteringBar = (
|
||||||
const [{ search, tags, startDate, endDate }, toFirstPage ] = useShortUrlsQuery(props);
|
colorGenerator: ColorGenerator,
|
||||||
const selectedTags = tags?.split(',') ?? [];
|
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
|
||||||
|
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => {
|
||||||
|
const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage ] = useShortUrlsQuery();
|
||||||
const setDates = pipe(
|
const setDates = pipe(
|
||||||
({ startDate, endDate }: DateRange) => ({
|
({ startDate, endDate }: DateRange) => ({
|
||||||
startDate: formatIsoDate(startDate) ?? undefined,
|
startDate: formatIsoDate(startDate) ?? undefined,
|
||||||
@@ -31,18 +48,27 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => (props: ShortU
|
|||||||
(search) => toFirstPage({ search }),
|
(search) => toFirstPage({ search }),
|
||||||
);
|
);
|
||||||
const removeTag = pipe(
|
const removeTag = pipe(
|
||||||
(tag: string) => selectedTags.filter((selectedTag) => selectedTag !== tag),
|
(tag: string) => tags.filter((selectedTag) => selectedTag !== tag),
|
||||||
(tagsList) => tagsList.length === 0 ? undefined : tagsList.join(','),
|
(updateTags) => toFirstPage({ tags: updateTags }),
|
||||||
(tags) => toFirstPage({ tags }),
|
);
|
||||||
|
const canChangeTagsMode = supportsAllTagsFiltering(selectedServer);
|
||||||
|
const toggleTagsMode = pipe(
|
||||||
|
() => tagsMode === 'any' ? 'all' : 'any',
|
||||||
|
(tagsMode) => toFirstPage({ tagsMode }),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="short-urls-filtering-bar-container">
|
<div className={classNames('short-urls-filtering-bar-container', className)}>
|
||||||
<SearchField initialValue={search} onChange={setSearch} />
|
<SearchField initialValue={search} onChange={setSearch} />
|
||||||
|
|
||||||
<div className="mt-3">
|
<Row className="flex-column-reverse flex-lg-row">
|
||||||
<div className="row">
|
<div className="col-lg-4 col-xl-6 mt-3">
|
||||||
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
|
<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
|
<DateRangeSelector
|
||||||
defaultText="All short URLs"
|
defaultText="All short URLs"
|
||||||
initialDateRange={{
|
initialDateRange={{
|
||||||
@@ -52,14 +78,23 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => (props: ShortU
|
|||||||
onDatesChange={setDates}
|
onDatesChange={setDates}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Row>
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedTags.length > 0 && (
|
{tags.length > 0 && (
|
||||||
<h4 className="short-urls-filtering-bar__selected-tag mt-3">
|
<h4 className="mt-3">
|
||||||
<FontAwesomeIcon icon={tagsIcon} className="short-urls-filtering-bar__tags-icon" />
|
{canChangeTagsMode && tags.length > 1 && (
|
||||||
|
<div className="float-end ms-2 mt-1">
|
||||||
{selectedTags.map((tag) =>
|
<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)} />)}
|
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
|
||||||
</h4>
|
</h4>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { pipe } from 'ramda';
|
import { pipe } from 'ramda';
|
||||||
import { FC, useEffect, useMemo, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { RouteComponentProps } from 'react-router';
|
|
||||||
import { Card } from 'reactstrap';
|
import { Card } from 'reactstrap';
|
||||||
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
import { useLocation, useParams } from 'react-router-dom';
|
||||||
import { determineOrderDir, OrderDir } from '../utils/helpers/ordering';
|
import { determineOrderDir, OrderDir } from '../utils/helpers/ordering';
|
||||||
import { getServerId, SelectedServer } from '../servers/data';
|
import { getServerId, SelectedServer } from '../servers/data';
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
@@ -13,32 +12,29 @@ import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/sett
|
|||||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||||
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
||||||
import Paginator from './Paginator';
|
import Paginator from './Paginator';
|
||||||
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
|
import { useShortUrlsQuery } from './helpers/hooks';
|
||||||
import { ShortUrlsOrderableFields, SHORT_URLS_ORDERABLE_FIELDS } from './data';
|
import { ShortUrlsOrderableFields } from './data';
|
||||||
|
import { ShortUrlsFilteringProps } from './ShortUrlsFilteringBar';
|
||||||
|
|
||||||
interface ShortUrlsListProps extends RouteComponentProps<ShortUrlListRouteParams> {
|
interface ShortUrlsListProps {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
shortUrlsList: ShortUrlsListState;
|
shortUrlsList: ShortUrlsListState;
|
||||||
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, ShortUrlsFilteringBar: FC) => boundToMercureHub(({
|
const ShortUrlsList = (
|
||||||
listShortUrls,
|
ShortUrlsTable: FC<ShortUrlsTableProps>,
|
||||||
match,
|
ShortUrlsFilteringBar: FC<ShortUrlsFilteringProps>,
|
||||||
location,
|
) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => {
|
||||||
history,
|
|
||||||
shortUrlsList,
|
|
||||||
selectedServer,
|
|
||||||
settings,
|
|
||||||
}: ShortUrlsListProps) => {
|
|
||||||
const serverId = getServerId(selectedServer);
|
const serverId = getServerId(selectedServer);
|
||||||
const [{ tags, search, startDate, endDate, orderBy }, toFirstPage ] = useShortUrlsQuery({ history, match, location });
|
const { page } = useParams();
|
||||||
|
const location = useLocation();
|
||||||
|
const [{ tags, search, startDate, endDate, orderBy, tagsMode }, toFirstPage ] = useShortUrlsQuery();
|
||||||
const [ actualOrderBy, setActualOrderBy ] = useState(
|
const [ actualOrderBy, setActualOrderBy ] = useState(
|
||||||
// This separated state handling is needed to be able to fall back to settings value, but only once when loaded
|
// 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,
|
orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING,
|
||||||
);
|
);
|
||||||
const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]);
|
|
||||||
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
||||||
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
|
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
|
||||||
toFirstPage({ orderBy: { field, dir } });
|
toFirstPage({ orderBy: { field, dir } });
|
||||||
@@ -49,27 +45,31 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, ShortUrlsFilteri
|
|||||||
const renderOrderIcon = (field: ShortUrlsOrderableFields) =>
|
const renderOrderIcon = (field: ShortUrlsOrderableFields) =>
|
||||||
<TableOrderIcon currentOrder={actualOrderBy} field={field} />;
|
<TableOrderIcon currentOrder={actualOrderBy} field={field} />;
|
||||||
const addTag = pipe(
|
const addTag = pipe(
|
||||||
(newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','),
|
(newTag: string) => [ ...new Set([ ...tags, newTag ]) ],
|
||||||
(tags) => toFirstPage({ tags }),
|
(updatedTags) => toFirstPage({ tags: updatedTags }),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listShortUrls({
|
listShortUrls({
|
||||||
page: match.params.page,
|
page,
|
||||||
searchTerm: search,
|
searchTerm: search,
|
||||||
tags: selectedTags,
|
tags,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
orderBy: actualOrderBy,
|
orderBy: actualOrderBy,
|
||||||
|
tagsMode,
|
||||||
});
|
});
|
||||||
}, [ match.params.page, search, selectedTags, startDate, endDate, actualOrderBy ]);
|
}, [ page, search, tags, startDate, endDate, actualOrderBy, tagsMode ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-3"><ShortUrlsFilteringBar /></div>
|
<ShortUrlsFilteringBar
|
||||||
<div className="d-block d-lg-none mb-3">
|
selectedServer={selectedServer}
|
||||||
<OrderingDropdown items={SHORT_URLS_ORDERABLE_FIELDS} order={actualOrderBy} onChange={handleOrderBy} />
|
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
|
||||||
</div>
|
order={actualOrderBy}
|
||||||
|
handleOrderBy={handleOrderBy}
|
||||||
|
className="mb-3"
|
||||||
|
/>
|
||||||
<Card body className="pb-1">
|
<Card body className="pb-1">
|
||||||
<ShortUrlsTable
|
<ShortUrlsTable
|
||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
|||||||
const { error, loading, shortUrls } = shortUrlsList;
|
const { error, loading, shortUrls } = shortUrlsList;
|
||||||
const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn });
|
const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn });
|
||||||
const orderableColumnsClasses = classNames('short-urls-table__header-cell', actionableFieldClasses);
|
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 supportsTitle = supportsShortUrlTitle(selectedServer);
|
||||||
|
|
||||||
const renderShortUrls = () => {
|
const renderShortUrls = () => {
|
||||||
|
|||||||
@@ -63,3 +63,12 @@ export const SHORT_URLS_ORDERABLE_FIELDS = {
|
|||||||
export type ShortUrlsOrderableFields = keyof typeof SHORT_URLS_ORDERABLE_FIELDS;
|
export type ShortUrlsOrderableFields = keyof typeof SHORT_URLS_ORDERABLE_FIELDS;
|
||||||
|
|
||||||
export type ShortUrlsOrder = Order<ShortUrlsOrderableFields>;
|
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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Result type="error" className="mt-3">
|
<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 :(" />
|
<ShlinkApiError errorData={errorData} fallbackMessage="An error occurred while creating the URL :(" />
|
||||||
</Result>
|
</Result>
|
||||||
);
|
);
|
||||||
@@ -42,7 +42,7 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Result type="success" className="mt-3">
|
<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>
|
<b>Great!</b> The short URL is <b>{shortUrl}</b>
|
||||||
|
|
||||||
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
|
<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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import classNames from 'classnames';
|
|
||||||
import { ShortUrlModalProps } from '../data';
|
import { ShortUrlModalProps } from '../data';
|
||||||
import { SelectedServer } from '../../servers/data';
|
import { SelectedServer } from '../../servers/data';
|
||||||
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
||||||
@@ -56,10 +55,8 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Vers
|
|||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<Row>
|
<Row>
|
||||||
<FormGroup
|
<FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}>
|
||||||
className={classNames({ 'col-md-4': willRenderThreeControls, 'col-md-6': !willRenderThreeControls })}
|
<label>Size: {size}px</label>
|
||||||
>
|
|
||||||
<label className="mb-0">Size: {size}px</label>
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
className="form-control-range"
|
className="form-control-range"
|
||||||
@@ -71,8 +68,8 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Vers
|
|||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
{capabilities.marginIsSupported && (
|
{capabilities.marginIsSupported && (
|
||||||
<FormGroup className={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}>
|
<FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}>
|
||||||
<label className="mb-0">Margin: {margin}px</label>
|
<label>Margin: {margin}px</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
className="form-control-range"
|
className="form-control-range"
|
||||||
@@ -106,7 +103,7 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Vers
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={async () => imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`)}
|
onClick={async () => imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`)}
|
||||||
>
|
>
|
||||||
Download <FontAwesomeIcon icon={downloadIcon} className="ml-1" />
|
Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</ForServerVersion>
|
</ForServerVersion>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
|
|||||||
{ children, infoTooltip, checked, onChange },
|
{ children, infoTooltip, checked, onChange },
|
||||||
) => (
|
) => (
|
||||||
<p>
|
<p>
|
||||||
<Checkbox inline checked={checked} className={infoTooltip ? 'mr-2' : ''} onChange={onChange}>
|
<Checkbox inline checked={checked} className={infoTooltip ? 'me-2' : ''} onChange={onChange}>
|
||||||
{children}
|
{children}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
{infoTooltip && <InfoTooltip placement="right">{infoTooltip}</InfoTooltip>}
|
{infoTooltip && <InfoTooltip placement="right">{infoTooltip}</InfoTooltip>}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const ShortUrlsRow = (
|
|||||||
<span className="indivisible short-urls-row__cell--relative">
|
<span className="indivisible short-urls-row__cell--relative">
|
||||||
<ExternalLink href={shortUrl.shortUrl} />
|
<ExternalLink href={shortUrl.shortUrl} />
|
||||||
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
|
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
|
||||||
<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!
|
Copied short URL!
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -73,7 +73,7 @@ const ShortUrlsRow = (
|
|||||||
</td>
|
</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" 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
|
<ShortUrlVisitsCount
|
||||||
visitsCount={shortUrl.visitsCount}
|
visitsCount={shortUrl.visitsCount}
|
||||||
shortUrl={shortUrl}
|
shortUrl={shortUrl}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { RouteChildrenProps } from 'react-router-dom';
|
import { useParams, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { isEmpty, pipe } from 'ramda';
|
import { isEmpty, pipe } from 'ramda';
|
||||||
import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
|
import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
|
||||||
import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
|
import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
|
||||||
import { orderToString, stringToOrder } from '../../utils/helpers/ordering';
|
import { orderToString, stringToOrder } from '../../utils/helpers/ordering';
|
||||||
|
import { TagsFilteringMode } from '../../api/types';
|
||||||
|
|
||||||
type ServerIdRouteProps = RouteChildrenProps<{ serverId: string }>;
|
|
||||||
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
|
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
|
||||||
|
|
||||||
export interface ShortUrlListRouteParams {
|
export interface ShortUrlListRouteParams {
|
||||||
@@ -14,40 +14,50 @@ export interface ShortUrlListRouteParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ShortUrlsQueryCommon {
|
interface ShortUrlsQueryCommon {
|
||||||
tags?: string;
|
|
||||||
search?: string;
|
search?: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
|
tagsMode?: TagsFilteringMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShortUrlsQuery extends ShortUrlsQueryCommon {
|
interface ShortUrlsQuery extends ShortUrlsQueryCommon {
|
||||||
orderBy?: string;
|
orderBy?: string;
|
||||||
|
tags?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
|
interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
|
||||||
orderBy?: ShortUrlsOrder;
|
orderBy?: ShortUrlsOrder;
|
||||||
|
tags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useShortUrlsQuery = (
|
export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
|
||||||
{ history, location, match }: ServerIdRouteProps,
|
const navigate = useNavigate();
|
||||||
): [ShortUrlsFiltering, ToFirstPage] => {
|
const location = useLocation();
|
||||||
|
const params = useParams<{ serverId: string }>();
|
||||||
|
|
||||||
const query = useMemo(
|
const query = useMemo(
|
||||||
pipe(
|
pipe(
|
||||||
() => parseQuery<ShortUrlsQuery>(location.search),
|
() => parseQuery<ShortUrlsQuery>(location.search),
|
||||||
({ orderBy, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => !orderBy ? rest : {
|
({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => {
|
||||||
...rest,
|
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
|
||||||
orderBy: stringToOrder<ShortUrlsOrderableFields>(orderBy),
|
const parsedTags = tags?.split(',') ?? [];
|
||||||
|
|
||||||
|
return { ...rest, orderBy: parsedOrderBy, tags: parsedTags };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
[ location.search ],
|
[ location.search ],
|
||||||
);
|
);
|
||||||
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
|
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
|
||||||
const { orderBy, ...mergedQuery } = { ...query, ...extra };
|
const { orderBy, tags, ...mergedQuery } = { ...query, ...extra };
|
||||||
const normalizedQuery: ShortUrlsQuery = { ...mergedQuery, orderBy: orderBy && orderToString(orderBy) };
|
const normalizedQuery: ShortUrlsQuery = {
|
||||||
|
...mergedQuery,
|
||||||
|
orderBy: orderBy && orderToString(orderBy),
|
||||||
|
tags: tags.length > 0 ? tags.join(',') : undefined,
|
||||||
|
};
|
||||||
const evolvedQuery = stringifyQuery(normalizedQuery);
|
const evolvedQuery = stringifyQuery(normalizedQuery);
|
||||||
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;
|
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 ];
|
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 { Action, Dispatch } from 'redux';
|
||||||
import { shortUrlMatches } from '../helpers';
|
import { shortUrlMatches } from '../helpers';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
|
import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
|
||||||
@@ -16,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';
|
export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
|
export const ITEMS_IN_OVERVIEW_PAGE = 5;
|
||||||
|
|
||||||
export interface ShortUrlsList {
|
export interface ShortUrlsList {
|
||||||
shortUrls?: ShlinkShortUrlsResponse;
|
shortUrls?: ShlinkShortUrlsResponse;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -75,10 +77,11 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
|
|||||||
),
|
),
|
||||||
[CREATE_SHORT_URL]: pipe(
|
[CREATE_SHORT_URL]: pipe(
|
||||||
// The only place where the list and the creation form coexist is the overview page.
|
// 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(
|
(state: ShortUrlsList, { result }: CreateShortUrlAction) => !state.shortUrls ? state : assocPath(
|
||||||
[ 'shortUrls', 'data' ],
|
[ 'shortUrls', 'data' ],
|
||||||
[ result, ...init(state.shortUrls.data) ],
|
[ result, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1) ],
|
||||||
state,
|
state,
|
||||||
),
|
),
|
||||||
(state: ShortUrlsList) => !state.shortUrls ? state : assocPath(
|
(state: ShortUrlsList) => !state.shortUrls ? state : assocPath(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import Bottle, { Decorator } from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import ShortUrlsFilteringBar from '../ShortUrlsFilteringBar';
|
import ShortUrlsFilteringBar from '../ShortUrlsFilteringBar';
|
||||||
import ShortUrlsList from '../ShortUrlsList';
|
import ShortUrlsList from '../ShortUrlsList';
|
||||||
import ShortUrlsRow from '../helpers/ShortUrlsRow';
|
import ShortUrlsRow from '../helpers/ShortUrlsRow';
|
||||||
@@ -16,8 +16,9 @@ import QrCodeModal from '../helpers/QrCodeModal';
|
|||||||
import { ShortUrlForm } from '../ShortUrlForm';
|
import { ShortUrlForm } from '../ShortUrlForm';
|
||||||
import { EditShortUrl } from '../EditShortUrl';
|
import { EditShortUrl } from '../EditShortUrl';
|
||||||
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
||||||
|
import { ExportShortUrlsBtn } from '../helpers/ExportShortUrlsBtn';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'ShortUrlsFilteringBar');
|
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'ShortUrlsFilteringBar');
|
||||||
bottle.decorator('ShortUrlsList', connect(
|
bottle.decorator('ShortUrlsList', connect(
|
||||||
@@ -49,9 +50,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
|||||||
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader', 'ForServerVersion');
|
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader', 'ForServerVersion');
|
||||||
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
|
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
|
||||||
|
|
||||||
// Services
|
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator', 'ExportShortUrlsBtn');
|
||||||
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator');
|
|
||||||
bottle.decorator('ShortUrlsFilteringBar', withRouter);
|
bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter');
|
||||||
|
bottle.decorator('ExportShortUrlsBtn', connect([ 'selectedServer' ]));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
||||||
|
|||||||
@@ -64,14 +64,14 @@ const TagCard = (
|
|||||||
to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}
|
to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}
|
||||||
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center mb-1"
|
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center mb-1"
|
||||||
>
|
>
|
||||||
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="mr-2" />Short URLs</span>
|
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="me-2" />Short URLs</span>
|
||||||
<b>{prettify(tag.shortUrls)}</b>
|
<b>{prettify(tag.shortUrls)}</b>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to={`/server/${serverId}/tag/${tag.tag}/visits`}
|
to={`/server/${serverId}/tag/${tag.tag}/visits`}
|
||||||
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center"
|
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center"
|
||||||
>
|
>
|
||||||
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
|
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="me-2" />Visits</span>
|
||||||
<b>{prettify(tag.visits)}</b>
|
<b>{prettify(tag.visits)}</b>
|
||||||
</Link>
|
</Link>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ interface TagsModeDropdownProps {
|
|||||||
export const TagsModeDropdown: FC<TagsModeDropdownProps> = ({ mode, onChange, renderTitle }) => (
|
export const TagsModeDropdown: FC<TagsModeDropdownProps> = ({ mode, onChange, renderTitle }) => (
|
||||||
<DropdownBtn text={renderTitle?.(mode) ?? `Display mode: ${mode}`}>
|
<DropdownBtn text={renderTitle?.(mode) ?? `Display mode: ${mode}`}>
|
||||||
<DropdownItem active={mode === 'cards'} onClick={() => onChange('cards')}>
|
<DropdownItem active={mode === 'cards'} onClick={() => onChange('cards')}>
|
||||||
<FontAwesomeIcon icon={cardsIcon} fixedWidth className="mr-1" /> Cards
|
<FontAwesomeIcon icon={cardsIcon} fixedWidth className="me-1" /> Cards
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem active={mode === 'list'} onClick={() => onChange('list')}>
|
<DropdownItem active={mode === 'list'} onClick={() => onChange('list')}>
|
||||||
<FontAwesomeIcon icon={listIcon} fixedWidth className="mr-1" /> List
|
<FontAwesomeIcon icon={listIcon} fixedWidth className="me-1" /> List
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</DropdownBtn>
|
</DropdownBtn>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FC, useEffect, useRef } from 'react';
|
import { FC, useEffect, useRef } from 'react';
|
||||||
import { splitEvery } from 'ramda';
|
import { splitEvery } from 'ramda';
|
||||||
import { RouteChildrenProps } from 'react-router';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import SimplePaginator from '../common/SimplePaginator';
|
import SimplePaginator from '../common/SimplePaginator';
|
||||||
import { useQueryState } from '../utils/helpers/hooks';
|
import { useQueryState } from '../utils/helpers/hooks';
|
||||||
@@ -18,10 +18,11 @@ export interface TagsTableProps extends TagsListChildrenProps {
|
|||||||
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
|
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
|
||||||
|
|
||||||
export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
|
export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
|
||||||
{ sortedTags, selectedServer, location, orderByColumn, currentOrder }: TagsTableProps & RouteChildrenProps,
|
{ sortedTags, selectedServer, orderByColumn, currentOrder }: TagsTableProps,
|
||||||
) => {
|
) => {
|
||||||
const isFirstLoad = useRef(true);
|
const isFirstLoad = useRef(true);
|
||||||
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(location.search);
|
const { search } = useLocation();
|
||||||
|
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(search);
|
||||||
const [ page, setPage ] = useQueryState<number>('page', Number(pageFromQuery));
|
const [ page, setPage ] = useQueryState<number>('page', Number(pageFromQuery));
|
||||||
const pages = splitEvery(TAGS_PER_PAGE, sortedTags);
|
const pages = splitEvery(TAGS_PER_PAGE, sortedTags);
|
||||||
const showPaginator = pages.length > 1;
|
const showPaginator = pages.length > 1;
|
||||||
@@ -37,16 +38,16 @@ export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SimpleCard key={page} bodyClassName={showPaginator ? 'pb-1' : ''}>
|
<SimpleCard key={page} bodyClassName={showPaginator ? 'pb-1' : ''}>
|
||||||
<table className="table table-hover mb-0">
|
<table className="table table-hover responsive-table mb-0">
|
||||||
<thead className="responsive-table__header">
|
<thead className="responsive-table__header">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="tags-table__header-cell" onClick={orderByColumn('tag')}>
|
<th className="tags-table__header-cell" onClick={orderByColumn('tag')}>
|
||||||
Tag <TableOrderIcon currentOrder={currentOrder} field="tag" />
|
Tag <TableOrderIcon currentOrder={currentOrder} field="tag" />
|
||||||
</th>
|
</th>
|
||||||
<th className="tags-table__header-cell text-lg-right" onClick={orderByColumn('shortUrls')}>
|
<th className="tags-table__header-cell text-lg-end" onClick={orderByColumn('shortUrls')}>
|
||||||
Short URLs <TableOrderIcon currentOrder={currentOrder} field="shortUrls" />
|
Short URLs <TableOrderIcon currentOrder={currentOrder} field="shortUrls" />
|
||||||
</th>
|
</th>
|
||||||
<th className="tags-table__header-cell text-lg-right" onClick={orderByColumn('visits')}>
|
<th className="tags-table__header-cell text-lg-end" onClick={orderByColumn('visits')}>
|
||||||
Visits <TableOrderIcon currentOrder={currentOrder} field="visits" />
|
Visits <TableOrderIcon currentOrder={currentOrder} field="visits" />
|
||||||
</th>
|
</th>
|
||||||
<th className="tags-table__header-cell" />
|
<th className="tags-table__header-cell" />
|
||||||
|
|||||||
@@ -31,23 +31,23 @@ export const TagsTableRow = (
|
|||||||
<th className="responsive-table__cell" data-th="Tag">
|
<th className="responsive-table__cell" data-th="Tag">
|
||||||
<TagBullet tag={tag.tag} colorGenerator={colorGenerator} /> {tag.tag}
|
<TagBullet tag={tag.tag} colorGenerator={colorGenerator} /> {tag.tag}
|
||||||
</th>
|
</th>
|
||||||
<td className="responsive-table__cell text-lg-right" data-th="Short URLs">
|
<td className="responsive-table__cell text-lg-end" data-th="Short URLs">
|
||||||
<Link to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}>
|
<Link to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}>
|
||||||
{prettify(tag.shortUrls)}
|
{prettify(tag.shortUrls)}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="responsive-table__cell text-lg-right" data-th="Visits">
|
<td className="responsive-table__cell text-lg-end" data-th="Visits">
|
||||||
<Link to={`/server/${serverId}/tag/${tag.tag}/visits`}>
|
<Link to={`/server/${serverId}/tag/${tag.tag}/visits`}>
|
||||||
{prettify(tag.visits)}
|
{prettify(tag.visits)}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="responsive-table__cell text-lg-right">
|
<td className="responsive-table__cell text-lg-end">
|
||||||
<DropdownBtnMenu toggle={toggleDropdown} isOpen={isDropdownOpen}>
|
<DropdownBtnMenu toggle={toggleDropdown} isOpen={isDropdownOpen}>
|
||||||
<DropdownItem onClick={toggleEdit}>
|
<DropdownItem onClick={toggleEdit}>
|
||||||
<FontAwesomeIcon icon={editIcon} fixedWidth className="mr-1" /> Edit
|
<FontAwesomeIcon icon={editIcon} fixedWidth className="me-1" /> Edit
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem onClick={toggleDelete}>
|
<DropdownItem onClick={toggleDelete}>
|
||||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth className="mr-1" /> Delete
|
<FontAwesomeIcon icon={deleteIcon} fixedWidth className="me-1" /> Delete
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</DropdownBtnMenu>
|
</DropdownBtnMenu>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -5,3 +5,7 @@
|
|||||||
.edit-tag-modal__color-icon {
|
.edit-tag-modal__color-icon {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-tag-modal__popover.edit-tag-modal__popover {
|
||||||
|
border-radius: .6rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Input, Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
import { Button, Input, Modal, ModalBody, ModalFooter, ModalHeader, Popover, InputGroup } from 'reactstrap';
|
||||||
import { ChromePicker } from 'react-color';
|
import { HexColorPicker } from 'react-colorful';
|
||||||
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { useToggle } from '../../utils/helpers/hooks';
|
import { useToggle } from '../../utils/helpers/hooks';
|
||||||
@@ -37,17 +37,24 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
|||||||
<form onSubmit={saveTag}>
|
<form onSubmit={saveTag}>
|
||||||
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
|
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<div className="input-group">
|
<InputGroup>
|
||||||
<div className="input-group-prepend" id="colorPickerBtn" onClick={toggleColorPicker}>
|
|
||||||
<div
|
<div
|
||||||
|
id="colorPickerBtn"
|
||||||
className="input-group-text edit-tag-modal__color-picker-toggle"
|
className="input-group-text edit-tag-modal__color-picker-toggle"
|
||||||
style={{ backgroundColor: color, borderColor: color }}
|
style={{ backgroundColor: color, borderColor: color }}
|
||||||
|
onClick={toggleColorPicker}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={colorIcon} className="edit-tag-modal__color-icon" />
|
<FontAwesomeIcon icon={colorIcon} className="edit-tag-modal__color-icon" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Popover
|
||||||
<Popover isOpen={showColorPicker} toggle={toggleColorPicker} target="colorPickerBtn" placement="right">
|
isOpen={showColorPicker}
|
||||||
<ChromePicker color={color} disableAlpha onChange={({ hex }) => setColor(hex)} />
|
toggle={toggleColorPicker}
|
||||||
|
target="colorPickerBtn"
|
||||||
|
placement="right"
|
||||||
|
hideArrow
|
||||||
|
popperClassName="edit-tag-modal__popover"
|
||||||
|
>
|
||||||
|
<HexColorPicker color={color} onChange={setColor} />
|
||||||
</Popover>
|
</Popover>
|
||||||
<Input
|
<Input
|
||||||
value={newTagName}
|
value={newTagName}
|
||||||
@@ -55,7 +62,7 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
|||||||
required
|
required
|
||||||
onChange={({ target }) => setNewTagName(target.value)}
|
onChange={({ target }) => setNewTagName(target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</InputGroup>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Result type="error" small className="mt-2">
|
<Result type="error" small className="mt-2">
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag--light-bg {
|
||||||
|
color: #222 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.tag:not(:last-child) {
|
.tag:not(:last-child) {
|
||||||
margin-right: 3px;
|
margin-right: 3px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { FC, MouseEventHandler } from 'react';
|
import { FC, MouseEventHandler } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
import ColorGenerator from '../../utils/services/ColorGenerator';
|
import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||||
import './Tag.scss';
|
import './Tag.scss';
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ interface TagProps {
|
|||||||
|
|
||||||
const Tag: FC<TagProps> = ({ text, children, clearable, className = '', colorGenerator, onClick, onClose }) => (
|
const Tag: FC<TagProps> = ({ text, children, clearable, className = '', colorGenerator, onClick, onClose }) => (
|
||||||
<span
|
<span
|
||||||
className={`badge tag ${className}`}
|
className={classNames('badge tag', className, { 'tag--light-bg': colorGenerator.isColorLightForKey(text) })}
|
||||||
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
|
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { CreateVisit, Stats } from '../../visits/types';
|
|||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
import { TagStats } from '../data';
|
import { TagStats } from '../data';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
import { CREATE_SHORT_URL, CreateShortUrlAction } from '../../short-urls/reducers/shortUrlCreation';
|
||||||
import { DeleteTagAction, TAG_DELETED } from './tagDelete';
|
import { DeleteTagAction, TAG_DELETED } from './tagDelete';
|
||||||
import { EditTagAction, TAG_EDITED } from './tagEdit';
|
import { EditTagAction, TAG_EDITED } from './tagEdit';
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ interface FilterTagsAction extends Action<string> {
|
|||||||
type TagsCombinedAction = ListTagsAction
|
type TagsCombinedAction = ListTagsAction
|
||||||
& DeleteTagAction
|
& DeleteTagAction
|
||||||
& CreateVisitsAction
|
& CreateVisitsAction
|
||||||
|
& CreateShortUrlAction
|
||||||
& EditTagAction
|
& EditTagAction
|
||||||
& FilterTagsAction
|
& FilterTagsAction
|
||||||
& ApiErrorAction;
|
& ApiErrorAction;
|
||||||
@@ -102,6 +104,10 @@ export default buildReducer<TagsList, TagsCombinedAction>({
|
|||||||
...state,
|
...state,
|
||||||
stats: increaseVisitsForTags(calculateVisitsPerTag(createdVisits), state.stats),
|
stats: increaseVisitsForTags(calculateVisitsPerTag(createdVisits), state.stats),
|
||||||
}),
|
}),
|
||||||
|
[CREATE_SHORT_URL]: ({ tags: stateTags, ...rest }, { result }) => ({
|
||||||
|
...rest,
|
||||||
|
tags: stateTags.concat(result.tags.filter((tag) => !stateTags.includes(tag))), // More performant than [ ...new Set(...) ]
|
||||||
|
}),
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = true) => () => async (
|
export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = true) => () => async (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Bottle, { IContainer } from 'bottlejs';
|
import Bottle, { IContainer } from 'bottlejs';
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
import TagsSelector from '../helpers/TagsSelector';
|
import TagsSelector from '../helpers/TagsSelector';
|
||||||
import TagCard from '../TagCard';
|
import TagCard from '../TagCard';
|
||||||
import DeleteTagConfirmModal from '../helpers/DeleteTagConfirmModal';
|
import DeleteTagConfirmModal from '../helpers/DeleteTagConfirmModal';
|
||||||
@@ -30,7 +29,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||||||
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
|
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
|
||||||
|
|
||||||
bottle.serviceFactory('TagsTable', TagsTable, 'TagsTableRow');
|
bottle.serviceFactory('TagsTable', TagsTable, 'TagsTableRow');
|
||||||
bottle.decorator('TagsTable', withRouter);
|
|
||||||
|
|
||||||
bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable');
|
bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable');
|
||||||
bottle.decorator('TagsList', connect(
|
bottle.decorator('TagsList', connect(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
$lightPrimaryColor: #ffffff;
|
$lightPrimaryColor: #ffffff;
|
||||||
$lightPrimaryColorAlfa: rgba($lightPrimaryColor, .5);
|
$lightPrimaryColorAlfa: rgba($lightPrimaryColor, .5);
|
||||||
$lightSecondaryColor: $lightColor;
|
$lightSecondaryColor: $lightColor;
|
||||||
$lightTextColor: #212529;
|
$lightTextColor: #232323;
|
||||||
$lightBorderColor: rgba(0, 0, 0, .125);
|
$lightBorderColor: rgba(0, 0, 0, .125);
|
||||||
$lightTableBorderColor: $mediumGrey;
|
$lightTableBorderColor: $mediumGrey;
|
||||||
$lightActiveColor: $lightGrey;
|
$lightActiveColor: $lightGrey;
|
||||||
@@ -44,6 +44,7 @@ html:not([data-theme='dark']) {
|
|||||||
--input-text-color: #{$lightInputTextColor};
|
--input-text-color: #{$lightInputTextColor};
|
||||||
--table-border-color: #{$lightTableBorderColor};
|
--table-border-color: #{$lightTableBorderColor};
|
||||||
--table-highlight-color: #{$lightTableHighlightColor};
|
--table-highlight-color: #{$lightTableHighlightColor};
|
||||||
|
--btn-close-filter: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme='dark'] {
|
html[data-theme='dark'] {
|
||||||
@@ -60,4 +61,5 @@ html[data-theme='dark'] {
|
|||||||
--input-text-color: #{$darkInputTextColor};
|
--input-text-color: #{$darkInputTextColor};
|
||||||
--table-border-color: #{$darkTableBorderColor};
|
--table-border-color: #{$darkTableBorderColor};
|
||||||
--table-highlight-color: #{$darkTableHighlightColor};
|
--table-highlight-color: #{$darkTableHighlightColor};
|
||||||
|
--btn-close-filter: invert(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,15 +20,15 @@ const BooleanControl: FC<BooleanControlWithTypeProps> = (
|
|||||||
const { current: id } = useRef(uuid());
|
const { current: id } = useRef(uuid());
|
||||||
const onChecked = (e: ChangeEvent<HTMLInputElement>) => onChange(e.target.checked, e);
|
const onChecked = (e: ChangeEvent<HTMLInputElement>) => onChange(e.target.checked, e);
|
||||||
const typeClasses = {
|
const typeClasses = {
|
||||||
'custom-switch': type === 'switch',
|
'form-switch': type === 'switch',
|
||||||
'custom-checkbox': type === 'checkbox',
|
'form-checkbox': type === 'checkbox',
|
||||||
};
|
};
|
||||||
const style = inline ? { display: 'inline-block' } : {};
|
const style = inline ? { display: 'inline-block' } : {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={classNames('custom-control', typeClasses, className)} style={style}>
|
<span className={classNames('form-check', typeClasses, className)} style={style}>
|
||||||
<input type="checkbox" className="custom-control-input" id={id} checked={checked} onChange={onChecked} />
|
<input type="checkbox" className="form-check-input" id={id} checked={checked} onChange={onChecked} />
|
||||||
<label className="custom-control-label" htmlFor={id}>{children}</label>
|
<label className="form-check-label" htmlFor={id}>{children}</label>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ interface CopyToClipboardIconProps {
|
|||||||
|
|
||||||
export const CopyToClipboardIcon: FC<CopyToClipboardIconProps> = ({ text, onCopy }) => (
|
export const CopyToClipboardIcon: FC<CopyToClipboardIconProps> = ({ text, onCopy }) => (
|
||||||
<CopyToClipboard text={text} onCopy={onCopy}>
|
<CopyToClipboard text={text} onCopy={onCopy}>
|
||||||
<FontAwesomeIcon icon={copyIcon} className="ml-2 copy-to-clipboard-icon" />
|
<FontAwesomeIcon icon={copyIcon} className="ms-2 copy-to-clipboard-icon" />
|
||||||
</CopyToClipboard>
|
</CopyToClipboard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -88,8 +88,9 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|
||||||
&[data-placement^='top'] .react-datepicker__triangle.react-datepicker__triangle {
|
&[data-placement^='top'] .react-datepicker__triangle.react-datepicker__triangle {
|
||||||
|
&::after {
|
||||||
border-top-color: var(--primary-color);
|
border-top-color: var(--primary-color);
|
||||||
border-bottom-color: var(--border-color);
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
border-top-color: var(--border-color);
|
border-top-color: var(--border-color);
|
||||||
@@ -97,8 +98,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&[data-placement^='bottom'] .react-datepicker__triangle.react-datepicker__triangle {
|
&[data-placement^='bottom'] .react-datepicker__triangle.react-datepicker__triangle {
|
||||||
border-top-color: var(--border-color);
|
&::after {
|
||||||
border-bottom-color: var(--secondary-color);
|
border-bottom-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
border-bottom-color: var(--border-color);
|
border-bottom-color: var(--border-color);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const DropdownBtn: FC<DropdownBtnProps> = (
|
|||||||
return (
|
return (
|
||||||
<Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled} className={dropdownClassName}>
|
<Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled} className={dropdownClassName}>
|
||||||
<DropdownToggle caret className={toggleClasses} color="primary">{text}</DropdownToggle>
|
<DropdownToggle caret className={toggleClasses} color="primary">{text}</DropdownToggle>
|
||||||
<DropdownMenu className="w-100" right={right} style={style}>{children}</DropdownMenu>
|
<DropdownMenu className="w-100" end={right} style={style}>{children}</DropdownMenu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,6 @@ export const DropdownBtnMenu: FC<DropdownBtnMenuProps> = ({ isOpen, toggle, chil
|
|||||||
<DropdownToggle size="sm" caret outline className="dropdown-btn-menu__dropdown-toggle">
|
<DropdownToggle size="sm" caret outline className="dropdown-btn-menu__dropdown-toggle">
|
||||||
<FontAwesomeIcon icon={menuIcon} />
|
<FontAwesomeIcon icon={menuIcon} />
|
||||||
</DropdownToggle>
|
</DropdownToggle>
|
||||||
<DropdownMenu right={right}>{children}</DropdownMenu>
|
<DropdownMenu end={right}>{children}</DropdownMenu>
|
||||||
</ButtonDropdown>
|
</ButtonDropdown>
|
||||||
);
|
);
|
||||||
|
|||||||
16
src/utils/ExportBtn.tsx
Normal file
16
src/utils/ExportBtn.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { Button, ButtonProps } from 'reactstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faFileDownload } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { prettify } from './helpers/numbers';
|
||||||
|
|
||||||
|
interface ExportBtnProps extends Omit<ButtonProps, 'outline' | 'color' | 'disabled'> {
|
||||||
|
amount?: number;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExportBtn: FC<ExportBtnProps> = ({ amount = 0, loading = false, ...rest }) => (
|
||||||
|
<Button {...rest} outline color="primary" disabled={loading}>
|
||||||
|
<FontAwesomeIcon icon={faFileDownload} /> {loading ? 'Exporting...' : <>Export ({prettify(amount)})</>}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { FC, useRef } from 'react';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
import { InputType } from 'reactstrap/lib/Input';
|
|
||||||
import { FormGroup } from 'reactstrap';
|
|
||||||
|
|
||||||
export interface FormGroupContainerProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (newValue: string) => void;
|
|
||||||
id?: string;
|
|
||||||
type?: InputType;
|
|
||||||
required?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
className?: string;
|
|
||||||
labelClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormGroupContainer: FC<FormGroupContainerProps> = (
|
|
||||||
{ children, value, onChange, id, type, required, placeholder, className, labelClassName },
|
|
||||||
) => {
|
|
||||||
const forId = useRef<string>(id ?? uuid());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormGroup className={className ?? ''}>
|
|
||||||
<label htmlFor={forId.current} className={labelClassName ?? ''}>
|
|
||||||
{children}:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
type={type ?? 'text'}
|
|
||||||
id={forId.current}
|
|
||||||
value={value}
|
|
||||||
required={required ?? true}
|
|
||||||
placeholder={placeholder}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { FC, useRef } from 'react';
|
import { FC, useRef } from 'react';
|
||||||
import * as Popper from 'popper.js';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import { Placement } from '@popperjs/core';
|
||||||
|
|
||||||
interface InfoTooltipProps {
|
interface InfoTooltipProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
placement: Popper.Placement;
|
placement: Placement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InfoTooltip: FC<InfoTooltipProps> = ({ className = '', placement, children }) => {
|
export const InfoTooltip: FC<InfoTooltipProps> = ({ className = '', placement, children }) => {
|
||||||
|
|||||||
@@ -37,12 +37,12 @@ const Message: FC<MessageProps> = ({ className, children, loading = false, type
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row noGutters className={className}>
|
<Row className={classNames('g-0', className)}>
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
<Card className={getClassForType(type)} body>
|
<Card className={getClassForType(type)} body>
|
||||||
<h3 className={classNames('text-center mb-0', getTextClassForType(type))}>
|
<h3 className={classNames('text-center mb-0', getTextClassForType(type))}>
|
||||||
{loading && <FontAwesomeIcon icon={preloader} spin />}
|
{loading && <FontAwesomeIcon icon={preloader} spin />}
|
||||||
{loading && <span className="ml-2">{children ?? 'Loading...'}</span>}
|
{loading && <span className="ms-2">{children ?? 'Loading...'}</span>}
|
||||||
{!loading && children}
|
{!loading && children}
|
||||||
</h3>
|
</h3>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user