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