mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-22 17:10:26 +03:00
Merge pull request #859 from acelaya-forks/feature/external-web-component
Feature/external web component
This commit is contained in:
commit
30c07c6790
314 changed files with 95 additions and 20287 deletions
|
@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* Extract `@shlinkio/shlink-frontend-kit` as external lib.
|
||||
* [#338](https://github.com/shlinkio/shlink-web-client/issues/338) Extract `@shlinkio/shlink-web-component` and `@shlinkio/shlink-frontend-kit` as external libs.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import 'vitest-canvas-mock';
|
||||
import 'chart.js/auto';
|
||||
import type { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers';
|
||||
import matchers from '@testing-library/jest-dom/matchers';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import ResizeObserver from 'resize-observer-polyfill';
|
||||
import { afterEach, expect } from 'vitest';
|
||||
|
||||
// Workaround for TypeScript error: https://github.com/testing-library/jest-dom/issues/439#issuecomment-1536524120
|
||||
|
@ -14,15 +11,10 @@ declare module 'vitest' {
|
|||
// Extends Vitest's expect method with methods from react-testing-library
|
||||
expect.extend(matchers);
|
||||
|
||||
// Clear all mocks and cleanup DOM after every test
|
||||
afterEach(() => {
|
||||
// Clears all mocks after every test
|
||||
vi.clearAllMocks();
|
||||
// Run a cleanup after each test case (e.g. clearing jsdom)
|
||||
cleanup();
|
||||
});
|
||||
|
||||
(global as any).ResizeObserver = ResizeObserver;
|
||||
(global as any).scrollTo = () => {};
|
||||
(global as any).prompt = () => {};
|
||||
(global as any).matchMedia = (media: string) => ({ matches: false, media });
|
||||
(global as any).HTMLElement.prototype.scrollIntoView = () => {};
|
||||
|
|
255
package-lock.json
generated
255
package-lock.json
generated
|
@ -16,30 +16,19 @@
|
|||
"@json2csv/plainjs": "^7.0.1",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"@shlinkio/shlink-frontend-kit": "^0.2.0",
|
||||
"@shlinkio/shlink-web-component": "^0.1.1",
|
||||
"bootstrap": "5.2.3",
|
||||
"bottlejs": "^2.0.1",
|
||||
"bowser": "^2.11.0",
|
||||
"chart.js": "^4.3.3",
|
||||
"classnames": "^2.3.2",
|
||||
"compare-versions": "^6.1.0",
|
||||
"csvtojson": "^2.0.10",
|
||||
"date-fns": "^2.30.0",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"history": "^5.3.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"ramda": "^0.27.2",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-datepicker": "^4.16.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-external-link": "^2.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-redux": "^8.1.2",
|
||||
"react-router-dom": "^6.14.2",
|
||||
"react-swipeable": "^7.0.1",
|
||||
"react-tag-autocomplete": "^7.0.0",
|
||||
"reactstrap": "^9.2.0",
|
||||
"redux-localstorage-simple": "^2.5.1",
|
||||
"uuid": "^9.0.0",
|
||||
|
@ -57,29 +46,23 @@
|
|||
"@testing-library/user-event": "^14.4.3",
|
||||
"@total-typescript/shoehorn": "^0.1.1",
|
||||
"@types/leaflet": "^1.9.3",
|
||||
"@types/qs": "^6.9.7",
|
||||
"@types/ramda": "^0.27.66",
|
||||
"@types/react": "^18.2.19",
|
||||
"@types/react-color": "^3.0.6",
|
||||
"@types/react-copy-to-clipboard": "^5.0.4",
|
||||
"@types/react-datepicker": "^4.15.0",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/react-tag-autocomplete": "^6.3.0",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"@vitejs/plugin-react": "^4.0.4",
|
||||
"@vitest/coverage-v8": "^0.34.1",
|
||||
"adm-zip": "^0.5.10",
|
||||
"chalk": "^5.3.0",
|
||||
"eslint": "^8.46.0",
|
||||
"history": "^5.3.0",
|
||||
"jsdom": "^22.1.0",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"sass": "^1.64.2",
|
||||
"stylelint": "^15.10.2",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-pwa": "^0.16.4",
|
||||
"vitest": "^0.34.1",
|
||||
"vitest-canvas-mock": "^0.3.2"
|
||||
"vitest": "^0.34.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@aashutoshrathi/word-wrap": {
|
||||
|
@ -3127,6 +3110,45 @@
|
|||
"reactstrap": "^9.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@shlinkio/shlink-web-component": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-web-component/-/shlink-web-component-0.1.1.tgz",
|
||||
"integrity": "sha512-N59LT9KCLPkPvPDPdS5EVvpPg0/eQUf1QuHewVZNK3Ee3XxAqoNK5ssHTuPX/xFDvzPmROEQWxIdcuma5+6e4w==",
|
||||
"dependencies": {
|
||||
"@json2csv/plainjs": "^7.0.1",
|
||||
"bottlejs": "^2.0.1",
|
||||
"bowser": "^2.11.0",
|
||||
"chart.js": "^4.3.3",
|
||||
"classnames": "^2.3.2",
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"leaflet": "^1.9.4",
|
||||
"ramda": "^0.27.2",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-datepicker": "^4.16.0",
|
||||
"react-external-link": "^2.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-swipeable": "^7.0.1",
|
||||
"react-tag-autocomplete": "^7.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"@shlinkio/shlink-frontend-kit": "^0.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^8.1.2",
|
||||
"react-router-dom": "^6.14.2",
|
||||
"reactstrap": "^9.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@shlinkio/stylelint-config-css-coding-standard": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@shlinkio/stylelint-config-css-coding-standard/-/stylelint-config-css-coding-standard-1.1.1.tgz",
|
||||
|
@ -3531,11 +3553,6 @@
|
|||
"version": "15.7.3",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.9.7",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ramda": {
|
||||
"version": "0.27.66",
|
||||
"resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.27.66.tgz",
|
||||
|
@ -3555,35 +3572,6 @@
|
|||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-color": {
|
||||
"version": "3.0.6",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/reactcss": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-copy-to-clipboard": {
|
||||
"version": "5.0.4",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-datepicker": {
|
||||
"version": "4.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-4.15.0.tgz",
|
||||
"integrity": "sha512-kr10s8ex4+MmCJmzdhA9kfmoMQBmsW5uDYDlH+8f/PgStrp7rRaz23Y/cvTiMgvESVq8ujDh4SOo6jlSwEw13g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.9.2",
|
||||
"@types/react": "*",
|
||||
"date-fns": "^2.0.1",
|
||||
"react-popper": "^2.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.7.tgz",
|
||||
|
@ -3593,22 +3581,6 @@
|
|||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-tag-autocomplete": {
|
||||
"version": "6.3.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/reactcss": {
|
||||
"version": "1.2.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
|
||||
|
@ -4917,11 +4889,6 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/cssfontparser": {
|
||||
"version": "1.2.1",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz",
|
||||
|
@ -6422,6 +6389,7 @@
|
|||
},
|
||||
"node_modules/history": {
|
||||
"version": "5.3.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.6"
|
||||
|
@ -7075,15 +7043,6 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-canvas-mock": {
|
||||
"version": "2.4.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssfontparser": "^1.2.1",
|
||||
"moo-color": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-diff": {
|
||||
"version": "29.3.1",
|
||||
"dev": true,
|
||||
|
@ -8065,14 +8024,6 @@
|
|||
"ufo": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/moo-color": {
|
||||
"version": "1.0.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "^1.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"dev": true,
|
||||
|
@ -9146,11 +9097,6 @@
|
|||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz",
|
||||
"integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="
|
||||
},
|
||||
"node_modules/resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.1",
|
||||
"dev": true,
|
||||
|
@ -10601,18 +10547,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest-canvas-mock": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vitest-canvas-mock/-/vitest-canvas-mock-0.3.2.tgz",
|
||||
"integrity": "sha512-lds7MKxvFFPDCGLXsQI2ym1fxvC93DaS0Bb6sdjvylFyL6NYrAAcPb6xZGF2sMOt5fSLHddqAQaujqpbc3p0Zg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"jest-canvas-mock": "~2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
|
@ -13333,6 +13267,31 @@
|
|||
"uuid": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"@shlinkio/shlink-web-component": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-web-component/-/shlink-web-component-0.1.1.tgz",
|
||||
"integrity": "sha512-N59LT9KCLPkPvPDPdS5EVvpPg0/eQUf1QuHewVZNK3Ee3XxAqoNK5ssHTuPX/xFDvzPmROEQWxIdcuma5+6e4w==",
|
||||
"requires": {
|
||||
"@json2csv/plainjs": "^7.0.1",
|
||||
"bottlejs": "^2.0.1",
|
||||
"bowser": "^2.11.0",
|
||||
"chart.js": "^4.3.3",
|
||||
"classnames": "^2.3.2",
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"leaflet": "^1.9.4",
|
||||
"ramda": "^0.27.2",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-datepicker": "^4.16.0",
|
||||
"react-external-link": "^2.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-swipeable": "^7.0.1",
|
||||
"react-tag-autocomplete": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@shlinkio/stylelint-config-css-coding-standard": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@shlinkio/stylelint-config-css-coding-standard/-/stylelint-config-css-coding-standard-1.1.1.tgz",
|
||||
|
@ -13638,10 +13597,6 @@
|
|||
"@types/prop-types": {
|
||||
"version": "15.7.3"
|
||||
},
|
||||
"@types/qs": {
|
||||
"version": "6.9.7",
|
||||
"dev": true
|
||||
},
|
||||
"@types/ramda": {
|
||||
"version": "0.27.66",
|
||||
"resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.27.66.tgz",
|
||||
|
@ -13661,33 +13616,6 @@
|
|||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"@types/react-color": {
|
||||
"version": "3.0.6",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*",
|
||||
"@types/reactcss": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-copy-to-clipboard": {
|
||||
"version": "5.0.4",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-datepicker": {
|
||||
"version": "4.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-4.15.0.tgz",
|
||||
"integrity": "sha512-kr10s8ex4+MmCJmzdhA9kfmoMQBmsW5uDYDlH+8f/PgStrp7rRaz23Y/cvTiMgvESVq8ujDh4SOo6jlSwEw13g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@popperjs/core": "^2.9.2",
|
||||
"@types/react": "*",
|
||||
"date-fns": "^2.0.1",
|
||||
"react-popper": "^2.2.5"
|
||||
}
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"version": "18.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.7.tgz",
|
||||
|
@ -13697,20 +13625,6 @@
|
|||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-tag-autocomplete": {
|
||||
"version": "6.3.0",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/reactcss": {
|
||||
"version": "1.2.3",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/resolve": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
|
||||
|
@ -14571,10 +14485,6 @@
|
|||
"version": "3.0.0",
|
||||
"dev": true
|
||||
},
|
||||
"cssfontparser": {
|
||||
"version": "1.2.1",
|
||||
"dev": true
|
||||
},
|
||||
"cssstyle": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz",
|
||||
|
@ -15599,6 +15509,7 @@
|
|||
},
|
||||
"history": {
|
||||
"version": "5.3.0",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.7.6"
|
||||
}
|
||||
|
@ -16014,14 +15925,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"jest-canvas-mock": {
|
||||
"version": "2.4.0",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cssfontparser": "^1.2.1",
|
||||
"moo-color": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"jest-diff": {
|
||||
"version": "29.3.1",
|
||||
"dev": true,
|
||||
|
@ -16697,13 +16600,6 @@
|
|||
"ufo": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"moo-color": {
|
||||
"version": "1.0.3",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-name": "^1.1.4"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.0.0",
|
||||
"dev": true,
|
||||
|
@ -17386,10 +17282,6 @@
|
|||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz",
|
||||
"integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="
|
||||
},
|
||||
"resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"dev": true
|
||||
},
|
||||
"resolve": {
|
||||
"version": "1.22.1",
|
||||
"dev": true,
|
||||
|
@ -18358,15 +18250,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"vitest-canvas-mock": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vitest-canvas-mock/-/vitest-canvas-mock-0.3.2.tgz",
|
||||
"integrity": "sha512-lds7MKxvFFPDCGLXsQI2ym1fxvC93DaS0Bb6sdjvylFyL6NYrAAcPb6xZGF2sMOt5fSLHddqAQaujqpbc3p0Zg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"jest-canvas-mock": "~2.4.0"
|
||||
}
|
||||
},
|
||||
"w3c-xmlserializer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
|
||||
|
|
27
package.json
27
package.json
|
@ -7,8 +7,8 @@
|
|||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "npm run lint:css && npm run lint:js",
|
||||
"lint:css": "stylelint src/*.scss src/**/*.scss shlink-web-component/*.scss shlink-web-component/**/*.scss",
|
||||
"lint:js": "eslint --ext .js,.ts,.tsx src shlink-web-component test",
|
||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||
"lint:js": "eslint --ext .js,.ts,.tsx src test",
|
||||
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
||||
"lint:css:fix": "npm run lint:css -- --fix",
|
||||
"lint:js:fix": "npm run lint:js -- --fix",
|
||||
|
@ -32,30 +32,19 @@
|
|||
"@json2csv/plainjs": "^7.0.1",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"@shlinkio/shlink-frontend-kit": "^0.2.0",
|
||||
"@shlinkio/shlink-web-component": "^0.1.1",
|
||||
"bootstrap": "5.2.3",
|
||||
"bottlejs": "^2.0.1",
|
||||
"bowser": "^2.11.0",
|
||||
"chart.js": "^4.3.3",
|
||||
"classnames": "^2.3.2",
|
||||
"compare-versions": "^6.1.0",
|
||||
"csvtojson": "^2.0.10",
|
||||
"date-fns": "^2.30.0",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"history": "^5.3.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"ramda": "^0.27.2",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-datepicker": "^4.16.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-external-link": "^2.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-redux": "^8.1.2",
|
||||
"react-router-dom": "^6.14.2",
|
||||
"react-swipeable": "^7.0.1",
|
||||
"react-tag-autocomplete": "^7.0.0",
|
||||
"reactstrap": "^9.2.0",
|
||||
"redux-localstorage-simple": "^2.5.1",
|
||||
"uuid": "^9.0.0",
|
||||
|
@ -73,29 +62,23 @@
|
|||
"@testing-library/user-event": "^14.4.3",
|
||||
"@total-typescript/shoehorn": "^0.1.1",
|
||||
"@types/leaflet": "^1.9.3",
|
||||
"@types/qs": "^6.9.7",
|
||||
"@types/ramda": "^0.27.66",
|
||||
"@types/react": "^18.2.19",
|
||||
"@types/react-color": "^3.0.6",
|
||||
"@types/react-copy-to-clipboard": "^5.0.4",
|
||||
"@types/react-datepicker": "^4.15.0",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/react-tag-autocomplete": "^6.3.0",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"@vitejs/plugin-react": "^4.0.4",
|
||||
"@vitest/coverage-v8": "^0.34.1",
|
||||
"adm-zip": "^0.5.10",
|
||||
"chalk": "^5.3.0",
|
||||
"eslint": "^8.46.0",
|
||||
"history": "^5.3.0",
|
||||
"jsdom": "^22.1.0",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"sass": "^1.64.2",
|
||||
"stylelint": "^15.10.2",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-pwa": "^0.16.4",
|
||||
"vitest": "^0.34.1",
|
||||
"vitest-canvas-mock": "^0.3.2"
|
||||
"vitest": "^0.34.1"
|
||||
},
|
||||
"browserslist": [
|
||||
">0.2%",
|
||||
|
|
10
shlink-web-client.d.ts
vendored
10
shlink-web-client.d.ts
vendored
|
@ -1,13 +1,3 @@
|
|||
// eslint-disable-next-line max-classes-per-file
|
||||
declare module 'event-source-polyfill' {
|
||||
declare class EventSourcePolyfill {
|
||||
public onmessage?: ({ data }: { data: string }) => void;
|
||||
public onerror?: ({ status }: { status: number }) => void;
|
||||
public close: () => void;
|
||||
public constructor(hubUrl: URL, options?: any);
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@json2csv/plainjs' {
|
||||
export class Parser {
|
||||
parse: <T>(data: T[]) => string;
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
|
||||
.shlink-layout__swipeable {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.shlink-layout__swipeable-inner {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.shlink-layout__burger-icon {
|
||||
display: none;
|
||||
transition: color 300ms;
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
z-index: 1035;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: rgb(255 255 255 / .5);
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.shlink-layout__burger-icon--active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.shlink-layout__container.shlink-layout__container {
|
||||
padding: 20px 0 0;
|
||||
min-height: 100%;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 30px 0 0 $asideMenuWidth;
|
||||
}
|
||||
}
|
|
@ -1,79 +0,0 @@
|
|||
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import classNames from 'classnames';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { Fragment, useEffect, useMemo } from 'react';
|
||||
import { BrowserRouter, Navigate, Route, Routes, useInRouterContext, useLocation } from 'react-router-dom';
|
||||
import { AsideMenu } from './common/AsideMenu';
|
||||
import { useFeature } from './utils/features';
|
||||
import { useSwipeable } from './utils/helpers/hooks';
|
||||
import { useRoutesPrefix } from './utils/routesPrefix';
|
||||
import './Main.scss';
|
||||
|
||||
export type MainProps = {
|
||||
createNotFound?: (nonPrefixedHomePath: string) => ReactNode;
|
||||
};
|
||||
|
||||
export const Main = (
|
||||
TagsList: FC,
|
||||
ShortUrlsList: FC,
|
||||
CreateShortUrl: FC,
|
||||
ShortUrlVisits: FC,
|
||||
TagVisits: FC,
|
||||
DomainVisits: FC,
|
||||
OrphanVisits: FC,
|
||||
NonOrphanVisits: FC,
|
||||
Overview: FC,
|
||||
EditShortUrl: FC,
|
||||
ManageDomains: FC,
|
||||
): FC<MainProps> => ({ createNotFound }) => {
|
||||
const location = useLocation();
|
||||
const routesPrefix = useRoutesPrefix();
|
||||
const inRouterContext = useInRouterContext();
|
||||
const [Wrapper, props] = useMemo(() => (
|
||||
inRouterContext
|
||||
? [Fragment, {}]
|
||||
: [BrowserRouter, { basename: routesPrefix }]
|
||||
), [inRouterContext]);
|
||||
|
||||
const [sidebarVisible, toggleSidebar, showSidebar, hideSidebar] = useToggle();
|
||||
useEffect(() => hideSidebar(), [location]);
|
||||
|
||||
const addDomainVisitsRoute = useFeature('domainVisits');
|
||||
const burgerClasses = classNames('shlink-layout__burger-icon', { 'shlink-layout__burger-icon--active': sidebarVisible });
|
||||
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
||||
|
||||
// FIXME Check if this works when not currently wrapped in a router
|
||||
|
||||
return (
|
||||
<Wrapper {...props}>
|
||||
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
||||
|
||||
<div {...swipeableProps} className="shlink-layout__swipeable">
|
||||
<div className="shlink-layout__swipeable-inner">
|
||||
<AsideMenu routePrefix={routesPrefix} showOnMobile={sidebarVisible} />
|
||||
<div className="shlink-layout__container" onClick={() => hideSidebar()}>
|
||||
<div className="container-xl">
|
||||
<Routes>
|
||||
<Route index element={<Navigate replace to="overview" />} />
|
||||
<Route path="/overview" element={<Overview />} />
|
||||
<Route path="/list-short-urls/:page" element={<ShortUrlsList />} />
|
||||
<Route path="/create-short-url" element={<CreateShortUrl />} />
|
||||
<Route path="/short-code/:shortCode/visits/*" element={<ShortUrlVisits />} />
|
||||
<Route path="/short-code/:shortCode/edit" element={<EditShortUrl />} />
|
||||
<Route path="/tag/:tag/visits/*" element={<TagVisits />} />
|
||||
{addDomainVisitsRoute && <Route path="/domain/:domain/visits/*" element={<DomainVisits />} />}
|
||||
<Route path="/orphan-visits/*" element={<OrphanVisits />} />
|
||||
<Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />
|
||||
<Route path="/manage-tags" element={<TagsList />} />
|
||||
<Route path="/manage-domains" element={<ManageDomains />} />
|
||||
{createNotFound && <Route path="*" element={createNotFound('/list-short-urls/1')} />}
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
|
@ -1,67 +0,0 @@
|
|||
import type { Store } from '@reduxjs/toolkit';
|
||||
import type Bottle from 'bottlejs';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Provider as ReduxStoreProvider } from 'react-redux';
|
||||
import type { ShlinkApiClient } from './api-contract';
|
||||
import { FeaturesProvider, useFeatures } from './utils/features';
|
||||
import type { SemVer } from './utils/helpers/version';
|
||||
import { RoutesPrefixProvider } from './utils/routesPrefix';
|
||||
import type { TagColorsStorage } from './utils/services/TagColorsStorage';
|
||||
import type { Settings } from './utils/settings';
|
||||
import { SettingsProvider } from './utils/settings';
|
||||
|
||||
type ShlinkWebComponentProps = {
|
||||
serverVersion: SemVer; // FIXME Consider making this optional and trying to resolve it if not set
|
||||
apiClient: ShlinkApiClient;
|
||||
tagColorsStorage?: TagColorsStorage;
|
||||
routesPrefix?: string;
|
||||
settings?: Settings;
|
||||
createNotFound?: (nonPrefixedHomePath: string) => ReactNode;
|
||||
};
|
||||
|
||||
// FIXME This allows to track the reference to be resolved by the container, but it's hacky and relies on not more than
|
||||
// one ShlinkWebComponent rendered at the same time.
|
||||
// Works for now, but should be addressed.
|
||||
let apiClientRef: ShlinkApiClient;
|
||||
|
||||
export const createShlinkWebComponent = (
|
||||
bottle: Bottle,
|
||||
): FC<ShlinkWebComponentProps> => (
|
||||
{ serverVersion, apiClient, settings, routesPrefix = '', createNotFound, tagColorsStorage },
|
||||
) => {
|
||||
const features = useFeatures(serverVersion);
|
||||
const mainContent = useRef<ReactNode>();
|
||||
const [theStore, setStore] = useState<Store | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
apiClientRef = apiClient;
|
||||
bottle.value('apiClientFactory', () => apiClientRef);
|
||||
|
||||
if (tagColorsStorage) {
|
||||
bottle.value('TagColorsStorage', tagColorsStorage);
|
||||
}
|
||||
|
||||
// It's important to not try to resolve services before the API client has been registered, as many other services
|
||||
// depend on it
|
||||
const { container } = bottle;
|
||||
const { Main, store, loadMercureInfo } = container;
|
||||
mainContent.current = <Main createNotFound={createNotFound} />;
|
||||
setStore(store);
|
||||
|
||||
// Load mercure info
|
||||
store.dispatch(loadMercureInfo(settings));
|
||||
}, [apiClient, tagColorsStorage]);
|
||||
|
||||
return !theStore ? <></> : (
|
||||
<ReduxStoreProvider store={theStore}>
|
||||
<SettingsProvider value={settings}>
|
||||
<FeaturesProvider value={features}>
|
||||
<RoutesPrefixProvider value={routesPrefix}>
|
||||
{mainContent.current}
|
||||
</RoutesPrefixProvider>
|
||||
</FeaturesProvider>
|
||||
</SettingsProvider>
|
||||
</ReduxStoreProvider>
|
||||
);
|
||||
};
|
|
@ -1,63 +0,0 @@
|
|||
import type {
|
||||
ShlinkCreateShortUrlData,
|
||||
ShlinkDomainRedirects,
|
||||
ShlinkDomainsResponse,
|
||||
ShlinkEditDomainRedirects,
|
||||
ShlinkEditShortUrlData,
|
||||
ShlinkHealth,
|
||||
ShlinkMercureInfo,
|
||||
ShlinkShortUrl,
|
||||
ShlinkShortUrlsListParams,
|
||||
ShlinkShortUrlsResponse,
|
||||
ShlinkTags,
|
||||
ShlinkVisits,
|
||||
ShlinkVisitsOverview,
|
||||
ShlinkVisitsParams,
|
||||
} from './types';
|
||||
|
||||
export type ShlinkApiClient = {
|
||||
readonly baseUrl: string;
|
||||
readonly apiKey: string;
|
||||
|
||||
listShortUrls(params?: ShlinkShortUrlsListParams): Promise<ShlinkShortUrlsResponse>;
|
||||
|
||||
createShortUrl(options: ShlinkCreateShortUrlData): Promise<ShlinkShortUrl>;
|
||||
|
||||
getShortUrlVisits(shortCode: string, query?: ShlinkVisitsParams): Promise<ShlinkVisits>;
|
||||
|
||||
getTagVisits(tag: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits>;
|
||||
|
||||
getDomainVisits(domain: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits>;
|
||||
|
||||
getOrphanVisits(query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits>;
|
||||
|
||||
getNonOrphanVisits(query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits>;
|
||||
|
||||
getVisitsOverview(): Promise<ShlinkVisitsOverview>;
|
||||
|
||||
getShortUrl(shortCode: string, domain?: string | null): Promise<ShlinkShortUrl>;
|
||||
|
||||
deleteShortUrl(shortCode: string, domain?: string | null): Promise<void>;
|
||||
|
||||
updateShortUrl(
|
||||
shortCode: string,
|
||||
domain: string | null | undefined,
|
||||
body: ShlinkEditShortUrlData,
|
||||
): Promise<ShlinkShortUrl>;
|
||||
|
||||
listTags(): Promise<ShlinkTags>;
|
||||
|
||||
tagsStats(): Promise<ShlinkTags>;
|
||||
|
||||
deleteTags(tags: string[]): Promise<{ tags: string[] }>;
|
||||
|
||||
editTag(oldName: string, newName: string): Promise<{ oldName: string; newName: string }>;
|
||||
|
||||
health(authority?: string): Promise<ShlinkHealth>;
|
||||
|
||||
mercureInfo(): Promise<ShlinkMercureInfo>;
|
||||
|
||||
listDomains(): Promise<ShlinkDomainsResponse>;
|
||||
|
||||
editDomainRedirects(domainRedirects: ShlinkEditDomainRedirects): Promise<ShlinkDomainRedirects>;
|
||||
};
|
|
@ -1,54 +0,0 @@
|
|||
export enum ErrorTypeV2 {
|
||||
INVALID_ARGUMENT = 'INVALID_ARGUMENT',
|
||||
INVALID_SHORT_URL_DELETION = 'INVALID_SHORT_URL_DELETION',
|
||||
DOMAIN_NOT_FOUND = 'DOMAIN_NOT_FOUND',
|
||||
FORBIDDEN_OPERATION = 'FORBIDDEN_OPERATION',
|
||||
INVALID_URL = 'INVALID_URL',
|
||||
INVALID_SLUG = 'INVALID_SLUG',
|
||||
INVALID_SHORTCODE = 'INVALID_SHORTCODE',
|
||||
TAG_CONFLICT = 'TAG_CONFLICT',
|
||||
TAG_NOT_FOUND = 'TAG_NOT_FOUND',
|
||||
MERCURE_NOT_CONFIGURED = 'MERCURE_NOT_CONFIGURED',
|
||||
INVALID_AUTHORIZATION = 'INVALID_AUTHORIZATION',
|
||||
INVALID_API_KEY = 'INVALID_API_KEY',
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
}
|
||||
|
||||
export enum ErrorTypeV3 {
|
||||
INVALID_ARGUMENT = 'https://shlink.io/api/error/invalid-data',
|
||||
INVALID_SHORT_URL_DELETION = 'https://shlink.io/api/error/invalid-short-url-deletion',
|
||||
DOMAIN_NOT_FOUND = 'https://shlink.io/api/error/domain-not-found',
|
||||
FORBIDDEN_OPERATION = 'https://shlink.io/api/error/forbidden-tag-operation',
|
||||
INVALID_URL = 'https://shlink.io/api/error/invalid-url',
|
||||
INVALID_SLUG = 'https://shlink.io/api/error/non-unique-slug',
|
||||
INVALID_SHORTCODE = 'https://shlink.io/api/error/short-url-not-found',
|
||||
TAG_CONFLICT = 'https://shlink.io/api/error/tag-conflict',
|
||||
TAG_NOT_FOUND = 'https://shlink.io/api/error/tag-not-found',
|
||||
MERCURE_NOT_CONFIGURED = 'https://shlink.io/api/error/mercure-not-configured',
|
||||
INVALID_AUTHORIZATION = 'https://shlink.io/api/error/missing-authentication',
|
||||
INVALID_API_KEY = 'https://shlink.io/api/error/invalid-api-key',
|
||||
NOT_FOUND = 'https://shlink.io/api/error/not-found',
|
||||
}
|
||||
|
||||
export interface ProblemDetailsError {
|
||||
type: string;
|
||||
detail: string;
|
||||
title: string;
|
||||
status: number;
|
||||
[extraProps: string]: any;
|
||||
}
|
||||
|
||||
export interface InvalidArgumentError extends ProblemDetailsError {
|
||||
type: ErrorTypeV2.INVALID_ARGUMENT | ErrorTypeV3.INVALID_ARGUMENT;
|
||||
invalidElements: string[];
|
||||
}
|
||||
|
||||
export interface InvalidShortUrlDeletion extends ProblemDetailsError {
|
||||
type: 'INVALID_SHORTCODE_DELETION' | ErrorTypeV2.INVALID_SHORT_URL_DELETION | ErrorTypeV3.INVALID_SHORT_URL_DELETION;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export interface RegularNotFound extends ProblemDetailsError {
|
||||
type: ErrorTypeV2.NOT_FOUND | ErrorTypeV3.NOT_FOUND;
|
||||
status: 404;
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export * from './errors';
|
||||
export * from './ShlinkApiClient';
|
||||
export * from './types';
|
|
@ -1,182 +0,0 @@
|
|||
import type { Order } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { Nullable, OptionalString } from '../utils/helpers';
|
||||
import type { Visit } from '../visits/types';
|
||||
|
||||
export interface ShlinkDeviceLongUrls {
|
||||
android?: OptionalString;
|
||||
ios?: OptionalString;
|
||||
desktop?: OptionalString;
|
||||
}
|
||||
|
||||
export interface ShlinkShortUrlMeta {
|
||||
validSince?: string;
|
||||
validUntil?: string;
|
||||
maxVisits?: number;
|
||||
}
|
||||
|
||||
export interface ShlinkShortUrl {
|
||||
shortCode: string;
|
||||
shortUrl: string;
|
||||
longUrl: string;
|
||||
deviceLongUrls?: Required<ShlinkDeviceLongUrls>, // Optional only before Shlink 3.5.0
|
||||
dateCreated: string;
|
||||
/** @deprecated */
|
||||
visitsCount: number; // Deprecated since Shlink 3.4.0
|
||||
visitsSummary?: ShlinkVisitsSummary; // Optional only before Shlink 3.4.0
|
||||
meta: Required<Nullable<ShlinkShortUrlMeta>>;
|
||||
tags: string[];
|
||||
domain: string | null;
|
||||
title?: string | null;
|
||||
crawlable?: boolean;
|
||||
forwardQuery?: boolean;
|
||||
}
|
||||
|
||||
export interface ShlinkEditShortUrlData {
|
||||
longUrl?: string;
|
||||
title?: string | null;
|
||||
tags?: string[];
|
||||
deviceLongUrls?: ShlinkDeviceLongUrls;
|
||||
crawlable?: boolean;
|
||||
forwardQuery?: boolean;
|
||||
validSince?: string | null;
|
||||
validUntil?: string | null;
|
||||
maxVisits?: number | null;
|
||||
|
||||
/** @deprecated */
|
||||
validateUrl?: boolean;
|
||||
}
|
||||
|
||||
export interface ShlinkCreateShortUrlData extends Omit<ShlinkEditShortUrlData, 'deviceLongUrls'> {
|
||||
longUrl: string;
|
||||
customSlug?: string;
|
||||
shortCodeLength?: number;
|
||||
domain?: string;
|
||||
findIfExists?: boolean;
|
||||
deviceLongUrls?: {
|
||||
android?: string;
|
||||
ios?: string;
|
||||
desktop?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ShlinkShortUrlsResponse {
|
||||
data: ShlinkShortUrl[];
|
||||
pagination: ShlinkPaginator;
|
||||
}
|
||||
|
||||
export interface ShlinkMercureInfo {
|
||||
token: string;
|
||||
mercureHubUrl: string;
|
||||
}
|
||||
|
||||
export interface ShlinkHealth {
|
||||
status: 'pass' | 'fail';
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface ShlinkTagsStats {
|
||||
tag: string;
|
||||
shortUrlsCount: number;
|
||||
visitsSummary?: ShlinkVisitsSummary; // Optional only before Shlink 3.5.0
|
||||
|
||||
/** @deprecated */
|
||||
visitsCount: number;
|
||||
}
|
||||
|
||||
export interface ShlinkTags {
|
||||
tags: string[];
|
||||
stats: ShlinkTagsStats[];
|
||||
}
|
||||
|
||||
export interface ShlinkTagsResponse {
|
||||
data: string[];
|
||||
/** @deprecated Present only when withStats=true is provided, which is deprecated */
|
||||
stats: ShlinkTagsStats[];
|
||||
}
|
||||
|
||||
export interface ShlinkTagsStatsResponse {
|
||||
data: ShlinkTagsStats[];
|
||||
}
|
||||
|
||||
export interface ShlinkPaginator {
|
||||
currentPage: number;
|
||||
pagesCount: number;
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
export interface ShlinkVisitsSummary {
|
||||
total: number;
|
||||
nonBots: number;
|
||||
bots: number;
|
||||
}
|
||||
|
||||
export interface ShlinkVisits {
|
||||
data: Visit[];
|
||||
pagination: ShlinkPaginator;
|
||||
}
|
||||
|
||||
export interface ShlinkVisitsOverview {
|
||||
nonOrphanVisits?: ShlinkVisitsSummary; // Optional only before Shlink 3.5.0
|
||||
orphanVisits?: ShlinkVisitsSummary; // Optional only before Shlink 3.5.0
|
||||
|
||||
/** @deprecated */
|
||||
visitsCount: number;
|
||||
/** @deprecated */
|
||||
orphanVisitsCount: number;
|
||||
}
|
||||
|
||||
export interface ShlinkVisitsParams {
|
||||
domain?: string | null;
|
||||
page?: number;
|
||||
itemsPerPage?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
excludeBots?: boolean;
|
||||
}
|
||||
|
||||
export interface ShlinkDomainRedirects {
|
||||
baseUrlRedirect: string | null;
|
||||
regular404Redirect: string | null;
|
||||
invalidShortUrlRedirect: string | null;
|
||||
}
|
||||
|
||||
export interface ShlinkEditDomainRedirects extends Partial<ShlinkDomainRedirects> {
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export interface ShlinkDomain {
|
||||
domain: string;
|
||||
isDefault: boolean;
|
||||
redirects: ShlinkDomainRedirects;
|
||||
}
|
||||
|
||||
export interface ShlinkDomainsResponse {
|
||||
data: ShlinkDomain[];
|
||||
defaultRedirects: ShlinkDomainRedirects;
|
||||
}
|
||||
|
||||
export type TagsFilteringMode = 'all' | 'any';
|
||||
|
||||
type ShlinkShortUrlsOrderableFields = 'dateCreated' | 'shortCode' | 'longUrl' | 'title' | 'visits' | 'nonBotVisits';
|
||||
|
||||
export type ShlinkShortUrlsOrder = Order<ShlinkShortUrlsOrderableFields>;
|
||||
|
||||
export interface ShlinkShortUrlsListParams {
|
||||
page?: string;
|
||||
itemsPerPage?: number;
|
||||
searchTerm?: string;
|
||||
tags?: string[];
|
||||
tagsMode?: TagsFilteringMode;
|
||||
orderBy?: ShlinkShortUrlsOrder;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
excludeMaxVisitsReached?: boolean;
|
||||
excludePastValidUntil?: boolean;
|
||||
}
|
||||
|
||||
export interface ShlinkShortUrlsListNormalizedParams extends
|
||||
Omit<ShlinkShortUrlsListParams, 'orderBy' | 'excludeMaxVisitsReached' | 'excludePastValidUntil'> {
|
||||
orderBy?: string;
|
||||
excludeMaxVisitsReached?: 'true';
|
||||
excludePastValidUntil?: 'true';
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
import type {
|
||||
InvalidArgumentError,
|
||||
InvalidShortUrlDeletion,
|
||||
ProblemDetailsError,
|
||||
RegularNotFound } from './errors';
|
||||
import {
|
||||
ErrorTypeV2,
|
||||
ErrorTypeV3,
|
||||
} from './errors';
|
||||
|
||||
export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError =>
|
||||
error?.type === ErrorTypeV2.INVALID_ARGUMENT || error?.type === ErrorTypeV3.INVALID_ARGUMENT;
|
||||
|
||||
export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion =>
|
||||
error?.type === 'INVALID_SHORTCODE_DELETION'
|
||||
|| error?.type === ErrorTypeV2.INVALID_SHORT_URL_DELETION
|
||||
|| error?.type === ErrorTypeV3.INVALID_SHORT_URL_DELETION;
|
||||
|
||||
export const isRegularNotFound = (error?: ProblemDetailsError): error is RegularNotFound =>
|
||||
(error?.type === ErrorTypeV2.NOT_FOUND || error?.type === ErrorTypeV3.NOT_FOUND) && error?.status === 404;
|
||||
|
||||
const isProblemDetails = (e: unknown): e is ProblemDetailsError =>
|
||||
!!e && typeof e === 'object' && ['type', 'detail', 'title', 'status'].every((prop) => prop in e);
|
||||
|
||||
export const parseApiError = (e: unknown): ProblemDetailsError | undefined => (isProblemDetails(e) ? e : undefined);
|
|
@ -1,63 +0,0 @@
|
|||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
@import '../utils/mixins/vertical-align';
|
||||
|
||||
.aside-menu {
|
||||
width: $asideMenuWidth;
|
||||
background-color: var(--primary-color);
|
||||
box-shadow: rgb(0 0 0 / .05) 0 8px 15px;
|
||||
position: fixed !important;
|
||||
padding-top: 13px;
|
||||
padding-bottom: 10px;
|
||||
top: $headerHeight;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
z-index: 1010;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 30px 15px 15px;
|
||||
}
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
transition: left 300ms;
|
||||
top: $headerHeight - 3px;
|
||||
box-shadow: -10px 0 50px 11px rgb(0 0 0 / .55);
|
||||
}
|
||||
}
|
||||
|
||||
.aside-menu--hidden {
|
||||
@media (max-width: $smMax) {
|
||||
left: -($asideMenuWidth + 35px);
|
||||
}
|
||||
}
|
||||
|
||||
.aside-menu__nav {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.aside-menu__item {
|
||||
padding: 10px 20px;
|
||||
margin: 0 -15px;
|
||||
text-decoration: none !important;
|
||||
cursor: pointer;
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.aside-menu__item:hover {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.aside-menu__item--selected,
|
||||
.aside-menu__item--selected:hover {
|
||||
color: #ffffff;
|
||||
background-color: var(--brand-color);
|
||||
}
|
||||
|
||||
.aside-menu__item-text {
|
||||
margin-left: 8px;
|
||||
}
|
|
@ -1,71 +0,0 @@
|
|||
import {
|
||||
faGlobe as domainsIcon,
|
||||
faHome as overviewIcon,
|
||||
faLink as createIcon,
|
||||
faList as listIcon,
|
||||
faTags as tagsIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
import type { FC } from 'react';
|
||||
import type { NavLinkProps } from 'react-router-dom';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import './AsideMenu.scss';
|
||||
|
||||
export interface AsideMenuProps {
|
||||
routePrefix: string;
|
||||
showOnMobile?: boolean;
|
||||
}
|
||||
|
||||
interface AsideMenuItemProps extends NavLinkProps {
|
||||
to: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
|
||||
<NavLink
|
||||
className={({ isActive }) => classNames('aside-menu__item', className, { 'aside-menu__item--selected': isActive })}
|
||||
to={to}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
export const AsideMenu: FC<AsideMenuProps> = ({ routePrefix, showOnMobile = false }) => {
|
||||
const { pathname } = useLocation();
|
||||
const asideClass = classNames('aside-menu', {
|
||||
'aside-menu--hidden': !showOnMobile,
|
||||
});
|
||||
const buildPath = (suffix: string) => `${routePrefix}${suffix}`;
|
||||
|
||||
return (
|
||||
<aside className={asideClass}>
|
||||
<nav className="nav flex-column aside-menu__nav">
|
||||
<AsideMenuItem to={buildPath('/overview')}>
|
||||
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
|
||||
<span className="aside-menu__item-text">Overview</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem
|
||||
to={buildPath('/list-short-urls/1')}
|
||||
className={classNames({ 'aside-menu__item--selected': pathname.match('/list-short-urls') !== null })}
|
||||
>
|
||||
<FontAwesomeIcon fixedWidth icon={listIcon} />
|
||||
<span className="aside-menu__item-text">List short URLs</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/create-short-url')}>
|
||||
<FontAwesomeIcon fixedWidth icon={createIcon} flip="horizontal" />
|
||||
<span className="aside-menu__item-text">Create short URL</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||
<FontAwesomeIcon fixedWidth icon={tagsIcon} />
|
||||
<span className="aside-menu__item-text">Manage tags</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/manage-domains')}>
|
||||
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
|
||||
<span className="aside-menu__item-text">Manage domains</span>
|
||||
</AsideMenuItem>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
};
|
|
@ -1,18 +0,0 @@
|
|||
import type { ProblemDetailsError } from '../api-contract';
|
||||
import { isInvalidArgumentError } from '../api-contract/utils';
|
||||
|
||||
export interface ShlinkApiErrorProps {
|
||||
errorData?: ProblemDetailsError;
|
||||
fallbackMessage?: string;
|
||||
}
|
||||
|
||||
export const ShlinkApiError = ({ errorData, fallbackMessage }: ShlinkApiErrorProps) => (
|
||||
<>
|
||||
{errorData?.detail ?? fallbackMessage}
|
||||
{isInvalidArgumentError(errorData) && (
|
||||
<p className="mb-0">
|
||||
Invalid elements: [{errorData.invalidElements.join(', ')}]
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
|
@ -1,42 +0,0 @@
|
|||
import type { IContainer } from 'bottlejs';
|
||||
import Bottle from 'bottlejs';
|
||||
import { pick } from 'ramda';
|
||||
import { connect as reduxConnect } from 'react-redux';
|
||||
import { provideServices as provideDomainsServices } from '../domains/services/provideServices';
|
||||
import { provideServices as provideMercureServices } from '../mercure/services/provideServices';
|
||||
import { provideServices as provideOverviewServices } from '../overview/services/provideServices';
|
||||
import { provideServices as provideShortUrlsServices } from '../short-urls/services/provideServices';
|
||||
import { provideServices as provideTagsServices } from '../tags/services/provideServices';
|
||||
import { provideServices as provideUtilsServices } from '../utils/services/provideServices';
|
||||
import { provideServices as provideVisitsServices } from '../visits/services/provideServices';
|
||||
import { provideServices as provideWebComponentServices } from './provideServices';
|
||||
|
||||
type LazyActionMap = Record<string, Function>;
|
||||
|
||||
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||
|
||||
export const bottle = new Bottle();
|
||||
|
||||
export const { container } = bottle;
|
||||
|
||||
const lazyService = <T extends Function, K>(cont: IContainer, serviceName: string) =>
|
||||
(...args: any[]) => (cont[serviceName] as T)(...args) as K;
|
||||
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
|
||||
...map,
|
||||
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
||||
[actionName]: lazyService(container, actionName),
|
||||
});
|
||||
const connect: ConnectDecorator = (propsFromState: string[] | null, actionServiceNames: string[] = []) =>
|
||||
reduxConnect(
|
||||
propsFromState ? pick(propsFromState) : null,
|
||||
actionServiceNames.reduce(mapActionService, {}),
|
||||
);
|
||||
|
||||
provideWebComponentServices(bottle);
|
||||
provideShortUrlsServices(bottle, connect);
|
||||
provideTagsServices(bottle, connect);
|
||||
provideVisitsServices(bottle, connect);
|
||||
provideMercureServices(bottle);
|
||||
provideDomainsServices(bottle, connect);
|
||||
provideOverviewServices(bottle, connect);
|
||||
provideUtilsServices(bottle);
|
|
@ -1,23 +0,0 @@
|
|||
import type Bottle from 'bottlejs';
|
||||
import { Main } from '../Main';
|
||||
import { setUpStore } from './store';
|
||||
|
||||
export const provideServices = (bottle: Bottle) => {
|
||||
bottle.serviceFactory(
|
||||
'Main',
|
||||
Main,
|
||||
'TagsList',
|
||||
'ShortUrlsList',
|
||||
'CreateShortUrl',
|
||||
'ShortUrlVisits',
|
||||
'TagVisits',
|
||||
'DomainVisits',
|
||||
'OrphanVisits',
|
||||
'NonOrphanVisits',
|
||||
'Overview',
|
||||
'EditShortUrl',
|
||||
'ManageDomains',
|
||||
);
|
||||
|
||||
bottle.factory('store', setUpStore);
|
||||
};
|
|
@ -1,65 +0,0 @@
|
|||
import { combineReducers, configureStore } from '@reduxjs/toolkit';
|
||||
import type { IContainer } from 'bottlejs';
|
||||
import type { DomainsList } from '../domains/reducers/domainsList';
|
||||
import type { MercureInfo } from '../mercure/reducers/mercureInfo';
|
||||
import type { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
|
||||
import type { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
|
||||
import type { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
|
||||
import type { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
|
||||
import type { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
|
||||
import type { TagDeletion } from '../tags/reducers/tagDelete';
|
||||
import type { TagEdition } from '../tags/reducers/tagEdit';
|
||||
import type { TagsList } from '../tags/reducers/tagsList';
|
||||
import type { DomainVisits } from '../visits/reducers/domainVisits';
|
||||
import type { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
||||
import type { TagVisits } from '../visits/reducers/tagVisits';
|
||||
import type { VisitsInfo } from '../visits/reducers/types';
|
||||
import type { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
export const setUpStore = (container: IContainer) => configureStore({
|
||||
devTools: !isProduction,
|
||||
reducer: combineReducers({
|
||||
mercureInfo: container.mercureInfoReducer,
|
||||
shortUrlsList: container.shortUrlsListReducer,
|
||||
shortUrlCreation: container.shortUrlCreationReducer,
|
||||
shortUrlDeletion: container.shortUrlDeletionReducer,
|
||||
shortUrlEdition: container.shortUrlEditionReducer,
|
||||
shortUrlDetail: container.shortUrlDetailReducer,
|
||||
shortUrlVisits: container.shortUrlVisitsReducer,
|
||||
tagVisits: container.tagVisitsReducer,
|
||||
domainVisits: container.domainVisitsReducer,
|
||||
orphanVisits: container.orphanVisitsReducer,
|
||||
nonOrphanVisits: container.nonOrphanVisitsReducer,
|
||||
tagsList: container.tagsListReducer,
|
||||
tagDelete: container.tagDeleteReducer,
|
||||
tagEdit: container.tagEditReducer,
|
||||
domainsList: container.domainsListReducer,
|
||||
visitsOverview: container.visitsOverviewReducer,
|
||||
}),
|
||||
middleware: (defaultMiddlewaresIncludingReduxThunk) => defaultMiddlewaresIncludingReduxThunk({
|
||||
// State is too big for these
|
||||
immutableCheck: false,
|
||||
serializableCheck: false,
|
||||
}),
|
||||
});
|
||||
|
||||
export type RootState = {
|
||||
shortUrlsList: ShortUrlsList;
|
||||
shortUrlCreation: ShortUrlCreation;
|
||||
shortUrlDeletion: ShortUrlDeletion;
|
||||
shortUrlEdition: ShortUrlEdition;
|
||||
shortUrlVisits: ShortUrlVisits;
|
||||
tagVisits: TagVisits;
|
||||
domainVisits: DomainVisits;
|
||||
orphanVisits: VisitsInfo;
|
||||
nonOrphanVisits: VisitsInfo;
|
||||
shortUrlDetail: ShortUrlDetail;
|
||||
tagsList: TagsList;
|
||||
tagDelete: TagDeletion;
|
||||
tagEdit: TagEdition;
|
||||
mercureInfo: MercureInfo;
|
||||
domainsList: DomainsList;
|
||||
visitsOverview: VisitsOverview;
|
||||
};
|
|
@ -1,62 +0,0 @@
|
|||
import { faDotCircle as defaultDomainIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import type { ShlinkDomainRedirects } from '../api-contract';
|
||||
import type { Domain } from './data';
|
||||
import { DomainDropdown } from './helpers/DomainDropdown';
|
||||
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
|
||||
import type { EditDomainRedirects } from './reducers/domainRedirects';
|
||||
|
||||
interface DomainRowProps {
|
||||
domain: Domain;
|
||||
defaultRedirects?: ShlinkDomainRedirects;
|
||||
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
||||
checkDomainHealth: (domain: string) => void;
|
||||
}
|
||||
|
||||
const Nr: FC<{ fallback?: string | null }> = ({ fallback }) => (
|
||||
<span className="text-muted">
|
||||
{!fallback && <small>No redirect</small>}
|
||||
{fallback && <>{fallback} <small>(as fallback)</small></>}
|
||||
</span>
|
||||
);
|
||||
const DefaultDomain: FC = () => (
|
||||
<>
|
||||
<FontAwesomeIcon fixedWidth icon={defaultDomainIcon} className="text-primary" id="defaultDomainIcon" />
|
||||
<UncontrolledTooltip target="defaultDomainIcon" placement="right">Default domain</UncontrolledTooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
export const DomainRow: FC<DomainRowProps> = (
|
||||
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects },
|
||||
) => {
|
||||
const { domain: authority, isDefault, redirects, status } = domain;
|
||||
|
||||
useEffect(() => {
|
||||
checkDomainHealth(domain.domain);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<tr className="responsive-table__row">
|
||||
<td className="responsive-table__cell" data-th="Is default domain">{isDefault && <DefaultDomain />}</td>
|
||||
<th className="responsive-table__cell" data-th="Domain">{authority}</th>
|
||||
<td className="responsive-table__cell" data-th="Base path redirect">
|
||||
{redirects?.baseUrlRedirect ?? <Nr fallback={defaultRedirects?.baseUrlRedirect} />}
|
||||
</td>
|
||||
<td className="responsive-table__cell" data-th="Regular 404 redirect">
|
||||
{redirects?.regular404Redirect ?? <Nr fallback={defaultRedirects?.regular404Redirect} />}
|
||||
</td>
|
||||
<td className="responsive-table__cell" data-th="Invalid short URL redirect">
|
||||
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
|
||||
</td>
|
||||
<td className="responsive-table__cell text-lg-center" data-th="Status">
|
||||
<DomainStatusIcon status={status} />
|
||||
</td>
|
||||
<td className="responsive-table__cell text-end">
|
||||
<DomainDropdown domain={domain} editDomainRedirects={editDomainRedirects} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
|
@ -1,19 +0,0 @@
|
|||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
@import '../utils/mixins/vertical-align';
|
||||
|
||||
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn,
|
||||
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:hover,
|
||||
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:active {
|
||||
color: $textPlaceholder !important;
|
||||
}
|
||||
|
||||
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active,
|
||||
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:hover,
|
||||
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:active {
|
||||
color: var(--input-text-color) !important;
|
||||
}
|
||||
|
||||
.domains-dropdown__back-btn.domains-dropdown__back-btn,
|
||||
.domains-dropdown__back-btn.domains-dropdown__back-btn:hover {
|
||||
border-color: var(--border-color);
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
import { faUndo } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { DropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import { isEmpty, pipe } from 'ramda';
|
||||
import { useEffect } from 'react';
|
||||
import type { InputProps } from 'reactstrap';
|
||||
import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip } from 'reactstrap';
|
||||
import type { DomainsList } from './reducers/domainsList';
|
||||
import './DomainSelector.scss';
|
||||
|
||||
export interface DomainSelectorProps extends Omit<InputProps, 'onChange'> {
|
||||
value?: string;
|
||||
onChange: (domain: string) => void;
|
||||
}
|
||||
|
||||
interface DomainSelectorConnectProps extends DomainSelectorProps {
|
||||
listDomains: Function;
|
||||
domainsList: DomainsList;
|
||||
}
|
||||
|
||||
export const DomainSelector = ({ listDomains, value, domainsList, onChange }: DomainSelectorConnectProps) => {
|
||||
const [inputDisplayed,, showInput, hideInput] = useToggle();
|
||||
const { domains } = domainsList;
|
||||
const valueIsEmpty = isEmpty(value);
|
||||
const unselectDomain = () => onChange('');
|
||||
|
||||
useEffect(() => {
|
||||
listDomains();
|
||||
}, []);
|
||||
|
||||
return inputDisplayed ? (
|
||||
<InputGroup>
|
||||
<Input
|
||||
value={value ?? ''}
|
||||
placeholder="Domain"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
id="backToDropdown"
|
||||
outline
|
||||
type="button"
|
||||
className="domains-dropdown__back-btn"
|
||||
aria-label="Back to domains list"
|
||||
onClick={pipe(unselectDomain, hideInput)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUndo} />
|
||||
</Button>
|
||||
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
|
||||
Existing domains
|
||||
</UncontrolledTooltip>
|
||||
</InputGroup>
|
||||
) : (
|
||||
<DropdownBtn
|
||||
text={valueIsEmpty ? 'Domain' : `Domain: ${value}`}
|
||||
className={!valueIsEmpty ? 'domains-dropdown__toggle-btn--active' : 'domains-dropdown__toggle-btn'}
|
||||
>
|
||||
{domains.map(({ domain, isDefault }) => (
|
||||
<DropdownItem
|
||||
key={domain}
|
||||
active={(value === domain || isDefault) && valueIsEmpty}
|
||||
onClick={() => onChange(domain)}
|
||||
>
|
||||
{domain}
|
||||
{isDefault && <span className="float-end text-muted">default</span>}
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownItem divider />
|
||||
<DropdownItem onClick={pipe(unselectDomain, showInput)}>
|
||||
<i>New domain</i>
|
||||
</DropdownItem>
|
||||
</DropdownBtn>
|
||||
);
|
||||
};
|
|
@ -1,71 +0,0 @@
|
|||
import { Message, Result, SearchField, SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { ShlinkApiError } from '../common/ShlinkApiError';
|
||||
import { DomainRow } from './DomainRow';
|
||||
import type { EditDomainRedirects } from './reducers/domainRedirects';
|
||||
import type { DomainsList } from './reducers/domainsList';
|
||||
|
||||
interface ManageDomainsProps {
|
||||
listDomains: Function;
|
||||
filterDomains: (searchTerm: string) => void;
|
||||
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
||||
checkDomainHealth: (domain: string) => void;
|
||||
domainsList: DomainsList;
|
||||
}
|
||||
|
||||
const headers = ['', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', ''];
|
||||
|
||||
export const ManageDomains: FC<ManageDomainsProps> = (
|
||||
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth },
|
||||
) => {
|
||||
const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList;
|
||||
const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
|
||||
|
||||
useEffect(() => {
|
||||
listDomains();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <Message loading />;
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<Result type="error">
|
||||
<ShlinkApiError errorData={errorData} fallbackMessage="Error loading domains :(" />
|
||||
</Result>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleCard>
|
||||
<table className="table table-hover responsive-table mb-0">
|
||||
<thead className="responsive-table__header">
|
||||
<tr>{headers.map((column, index) => <th key={index}>{column}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{domains.length < 1 && <tr><td colSpan={headers.length} className="text-center">No results found</td></tr>}
|
||||
{domains.map((domain) => (
|
||||
<DomainRow
|
||||
key={domain.domain}
|
||||
domain={domain}
|
||||
editDomainRedirects={editDomainRedirects}
|
||||
checkDomainHealth={checkDomainHealth}
|
||||
defaultRedirects={resolvedDefaultRedirects}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</SimpleCard>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchField className="mb-3" onChange={filterDomains} />
|
||||
{renderContent()}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,7 +0,0 @@
|
|||
import type { ShlinkDomain } from '../../api-contract';
|
||||
|
||||
export type DomainStatus = 'validating' | 'valid' | 'invalid';
|
||||
|
||||
export interface Domain extends ShlinkDomain {
|
||||
status: DomainStatus;
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
import { faChartPie as pieChartIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { RowDropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { useFeature } from '../../utils/features';
|
||||
import { useRoutesPrefix } from '../../utils/routesPrefix';
|
||||
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
|
||||
import type { Domain } from '../data';
|
||||
import type { EditDomainRedirects } from '../reducers/domainRedirects';
|
||||
import { EditDomainRedirectsModal } from './EditDomainRedirectsModal';
|
||||
|
||||
interface DomainDropdownProps {
|
||||
domain: Domain;
|
||||
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
||||
}
|
||||
|
||||
export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedirects }) => {
|
||||
const [isModalOpen, toggleModal] = useToggle();
|
||||
const withVisits = useFeature('domainVisits');
|
||||
const routesPrefix = useRoutesPrefix();
|
||||
|
||||
return (
|
||||
<RowDropdownBtn>
|
||||
{withVisits && (
|
||||
<DropdownItem
|
||||
tag={Link}
|
||||
to={`${routesPrefix}/domain/${domain.domain}${domain.isDefault ? `_${DEFAULT_DOMAIN}` : ''}/visits`}
|
||||
>
|
||||
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
||||
</DropdownItem>
|
||||
)}
|
||||
<DropdownItem onClick={toggleModal}>
|
||||
<FontAwesomeIcon fixedWidth icon={editIcon} /> Edit redirects
|
||||
</DropdownItem>
|
||||
|
||||
<EditDomainRedirectsModal
|
||||
domain={domain}
|
||||
isOpen={isModalOpen}
|
||||
toggle={toggleModal}
|
||||
editDomainRedirects={editDomainRedirects}
|
||||
/>
|
||||
</RowDropdownBtn>
|
||||
);
|
||||
};
|
|
@ -1,60 +0,0 @@
|
|||
import {
|
||||
faCheck as checkIcon,
|
||||
faCircleNotch as loadingStatusIcon,
|
||||
faTimes as invalidIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useElementRef } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import type { MediaMatcher } from '../../utils/types';
|
||||
import type { DomainStatus } from '../data';
|
||||
|
||||
interface DomainStatusIconProps {
|
||||
status: DomainStatus;
|
||||
matchMedia?: MediaMatcher;
|
||||
}
|
||||
|
||||
export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => {
|
||||
const ref = useElementRef<HTMLSpanElement>();
|
||||
const matchesMobile = () => matchMedia('(max-width: 991px)').matches;
|
||||
const [isMobile, setIsMobile] = useState<boolean>(matchesMobile());
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => setIsMobile(matchesMobile());
|
||||
|
||||
window.addEventListener('resize', listener);
|
||||
|
||||
return () => window.removeEventListener('resize', listener);
|
||||
}, []);
|
||||
|
||||
if (status === 'validating') {
|
||||
return <FontAwesomeIcon fixedWidth icon={loadingStatusIcon} spin />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={ref}>
|
||||
{status === 'valid'
|
||||
? <FontAwesomeIcon fixedWidth icon={checkIcon} className="text-muted" />
|
||||
: <FontAwesomeIcon fixedWidth icon={invalidIcon} className="text-danger" />}
|
||||
</span>
|
||||
<UncontrolledTooltip
|
||||
target={ref}
|
||||
placement={isMobile ? 'top-start' : 'left'}
|
||||
autohide={status === 'valid'}
|
||||
>
|
||||
{status === 'valid' ? 'Congratulations! This domain is properly configured.' : (
|
||||
<span>
|
||||
Oops! There is some missing configuration, and short URLs shared with this domain will not work.
|
||||
<br />
|
||||
Check the <ExternalLink href="https://slnk.to/multi-domain-docs">documentation</ExternalLink> in order to
|
||||
find out what is missing.
|
||||
</span>
|
||||
)}
|
||||
</UncontrolledTooltip>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,78 +0,0 @@
|
|||
import type { InputFormGroupProps } from '@shlinkio/shlink-frontend-kit';
|
||||
import { InputFormGroup } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import type { ShlinkDomain } from '../../api-contract';
|
||||
import { InfoTooltip } from '../../utils/components/InfoTooltip';
|
||||
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/helpers';
|
||||
import type { EditDomainRedirects } from '../reducers/domainRedirects';
|
||||
|
||||
interface EditDomainRedirectsModalProps {
|
||||
domain: ShlinkDomain;
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
||||
}
|
||||
|
||||
const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
|
||||
<InputFormGroup
|
||||
{...rest}
|
||||
required={false}
|
||||
type="url"
|
||||
placeholder="No redirect"
|
||||
className={isLast ? 'mb-0' : ''}
|
||||
/>
|
||||
);
|
||||
|
||||
export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
|
||||
{ isOpen, toggle, domain, editDomainRedirects },
|
||||
) => {
|
||||
const [baseUrlRedirect, setBaseUrlRedirect] = useState(domain.redirects?.baseUrlRedirect ?? '');
|
||||
const [regular404Redirect, setRegular404Redirect] = useState(domain.redirects?.regular404Redirect ?? '');
|
||||
const [invalidShortUrlRedirect, setInvalidShortUrlRedirect] = useState(
|
||||
domain.redirects?.invalidShortUrlRedirect ?? '',
|
||||
);
|
||||
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects({
|
||||
domain: domain.domain,
|
||||
redirects: {
|
||||
baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect),
|
||||
regular404Redirect: nonEmptyValueOrNull(regular404Redirect),
|
||||
invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect),
|
||||
},
|
||||
}).then(toggle));
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<form name="domainRedirectsModal" onSubmit={handleSubmit}>
|
||||
<ModalHeader toggle={toggle}>Edit redirects for <b>{domain.domain}</b></ModalHeader>
|
||||
<ModalBody>
|
||||
<FormGroup value={baseUrlRedirect} onChange={setBaseUrlRedirect}>
|
||||
<InfoTooltip className="me-2" placement="bottom">
|
||||
Visitors accessing the base url, as in <b>https://{domain.domain}/</b>, will be redirected to this URL.
|
||||
</InfoTooltip>
|
||||
Base URL
|
||||
</FormGroup>
|
||||
<FormGroup value={regular404Redirect} onChange={setRegular404Redirect}>
|
||||
<InfoTooltip className="me-2" placement="bottom">
|
||||
Visitors accessing a url not matching a short URL pattern, as in <b>https://{domain.domain}/???/[...]</b>,
|
||||
will be redirected to this URL.
|
||||
</InfoTooltip>
|
||||
Regular 404
|
||||
</FormGroup>
|
||||
<FormGroup value={invalidShortUrlRedirect} isLast onChange={setInvalidShortUrlRedirect}>
|
||||
<InfoTooltip className="me-2" placement="bottom">
|
||||
Visitors accessing a url matching a short URL pattern, but not matching an existing short code, will be
|
||||
redirected to this URL.
|
||||
</InfoTooltip>
|
||||
Invalid short URL
|
||||
</FormGroup>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="link" type="button" onClick={toggle}>Cancel</Button>
|
||||
<Button color="primary">Save</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
|
@ -1,20 +0,0 @@
|
|||
import type { ShlinkApiClient, ShlinkDomainRedirects } from '../../api-contract';
|
||||
import { createAsyncThunk } from '../../utils/redux';
|
||||
|
||||
const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
|
||||
|
||||
export interface EditDomainRedirects {
|
||||
domain: string;
|
||||
redirects: ShlinkDomainRedirects;
|
||||
}
|
||||
|
||||
export const editDomainRedirects = (
|
||||
apiClientFactory: () => ShlinkApiClient,
|
||||
) => createAsyncThunk(
|
||||
EDIT_DOMAIN_REDIRECTS,
|
||||
async ({ domain, redirects: providedRedirects }: EditDomainRedirects): Promise<EditDomainRedirects> => {
|
||||
const apiClient = apiClientFactory();
|
||||
const redirects = await apiClient.editDomainRedirects({ domain, ...providedRedirects });
|
||||
return { domain, redirects };
|
||||
},
|
||||
);
|
|
@ -1,108 +0,0 @@
|
|||
import type { AsyncThunk, SliceCaseReducers } from '@reduxjs/toolkit';
|
||||
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||
import type { ProblemDetailsError, ShlinkApiClient, ShlinkDomainRedirects } from '../../api-contract';
|
||||
import { parseApiError } from '../../api-contract/utils';
|
||||
import { createAsyncThunk } from '../../utils/redux';
|
||||
import type { Domain, DomainStatus } from '../data';
|
||||
import type { EditDomainRedirects } from './domainRedirects';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/domainsList';
|
||||
|
||||
export interface DomainsList {
|
||||
domains: Domain[];
|
||||
filteredDomains: Domain[];
|
||||
defaultRedirects?: ShlinkDomainRedirects;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
interface ListDomains {
|
||||
domains: Domain[];
|
||||
defaultRedirects?: ShlinkDomainRedirects;
|
||||
}
|
||||
|
||||
interface ValidateDomain {
|
||||
domain: string;
|
||||
status: DomainStatus;
|
||||
}
|
||||
|
||||
const initialState: DomainsList = {
|
||||
domains: [],
|
||||
filteredDomains: [],
|
||||
loading: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export const replaceRedirectsOnDomain = ({ domain, redirects }: EditDomainRedirects) =>
|
||||
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, redirects });
|
||||
|
||||
export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
|
||||
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, status });
|
||||
|
||||
export const domainsListReducerCreator = (
|
||||
apiClientFactory: () => ShlinkApiClient,
|
||||
editDomainRedirects: AsyncThunk<EditDomainRedirects, any, any>,
|
||||
) => {
|
||||
const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (): Promise<ListDomains> => {
|
||||
const { data, defaultRedirects } = await apiClientFactory().listDomains();
|
||||
|
||||
return {
|
||||
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
|
||||
defaultRedirects,
|
||||
};
|
||||
});
|
||||
|
||||
const checkDomainHealth = createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/checkDomainHealth`,
|
||||
async (domain: string): Promise<ValidateDomain> => {
|
||||
try {
|
||||
const { status } = await apiClientFactory().health(domain);
|
||||
return { domain, status: status === 'pass' ? 'valid' : 'invalid' };
|
||||
} catch (e) {
|
||||
return { domain, status: 'invalid' };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const filterDomains = createAction<string>(`${REDUCER_PREFIX}/filterDomains`);
|
||||
|
||||
const { reducer } = createSlice<DomainsList, SliceCaseReducers<DomainsList>>({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(listDomains.pending, () => ({ ...initialState, loading: true }));
|
||||
builder.addCase(listDomains.rejected, (_, { error }) => (
|
||||
{ ...initialState, error: true, errorData: parseApiError(error) }
|
||||
));
|
||||
builder.addCase(listDomains.fulfilled, (_, { payload }) => (
|
||||
{ ...initialState, ...payload, filteredDomains: payload.domains }
|
||||
));
|
||||
|
||||
builder.addCase(checkDomainHealth.fulfilled, ({ domains, filteredDomains, ...rest }, { payload }) => ({
|
||||
...rest,
|
||||
domains: domains.map(replaceStatusOnDomain(payload.domain, payload.status)),
|
||||
filteredDomains: filteredDomains.map(replaceStatusOnDomain(payload.domain, payload.status)),
|
||||
}));
|
||||
|
||||
builder.addCase(filterDomains, (state, { payload }) => ({
|
||||
...state,
|
||||
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(payload.toLowerCase())),
|
||||
}));
|
||||
|
||||
builder.addCase(editDomainRedirects.fulfilled, (state, { payload }) => ({
|
||||
...state,
|
||||
domains: state.domains.map(replaceRedirectsOnDomain(payload)),
|
||||
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(payload)),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
reducer,
|
||||
listDomains,
|
||||
checkDomainHealth,
|
||||
filterDomains,
|
||||
};
|
||||
};
|
|
@ -1,34 +0,0 @@
|
|||
import type Bottle from 'bottlejs';
|
||||
import { prop } from 'ramda';
|
||||
import type { ConnectDecorator } from '../../container';
|
||||
import { DomainSelector } from '../DomainSelector';
|
||||
import { ManageDomains } from '../ManageDomains';
|
||||
import { editDomainRedirects } from '../reducers/domainRedirects';
|
||||
import { domainsListReducerCreator } from '../reducers/domainsList';
|
||||
|
||||
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory('DomainSelector', () => DomainSelector);
|
||||
bottle.decorator('DomainSelector', connect(['domainsList'], ['listDomains']));
|
||||
|
||||
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
||||
bottle.decorator('ManageDomains', connect(
|
||||
['domainsList'],
|
||||
['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'],
|
||||
));
|
||||
|
||||
// Reducer
|
||||
bottle.serviceFactory(
|
||||
'domainsListReducerCreator',
|
||||
domainsListReducerCreator,
|
||||
'apiClientFactory',
|
||||
'editDomainRedirects',
|
||||
);
|
||||
bottle.serviceFactory('domainsListReducer', prop('reducer'), 'domainsListReducerCreator');
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('listDomains', prop('listDomains'), 'domainsListReducerCreator');
|
||||
bottle.serviceFactory('filterDomains', prop('filterDomains'), 'domainsListReducerCreator');
|
||||
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'apiClientFactory');
|
||||
bottle.serviceFactory('checkDomainHealth', prop('checkDomainHealth'), 'domainsListReducerCreator');
|
||||
};
|
|
@ -1,2 +0,0 @@
|
|||
@import './tags/react-tag-autocomplete';
|
||||
@import './utils/StickyCardPaginator.scss';
|
|
@ -1,18 +0,0 @@
|
|||
import { bottle } from './container';
|
||||
import { createShlinkWebComponent } from './ShlinkWebComponent';
|
||||
|
||||
export const ShlinkWebComponent = createShlinkWebComponent(bottle);
|
||||
|
||||
export type ShlinkWebComponentType = typeof ShlinkWebComponent;
|
||||
|
||||
export type {
|
||||
RealTimeUpdatesSettings,
|
||||
ShortUrlCreationSettings,
|
||||
ShortUrlsListSettings,
|
||||
UiSettings,
|
||||
VisitsSettings,
|
||||
TagsSettings,
|
||||
Settings,
|
||||
} from './utils/settings';
|
||||
|
||||
export type { TagColorsStorage } from './utils/services/TagColorsStorage';
|
|
@ -1,7 +0,0 @@
|
|||
export class Topics {
|
||||
public static readonly visits = 'https://shlink.io/new-visit';
|
||||
|
||||
public static readonly orphanVisits = 'https://shlink.io/new-orphan-visit';
|
||||
|
||||
public static readonly shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`;
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
import { pipe } from 'ramda';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type { CreateVisit } from '../../visits/types';
|
||||
import type { MercureInfo } from '../reducers/mercureInfo';
|
||||
import { bindToMercureTopic } from './index';
|
||||
|
||||
export interface MercureBoundProps {
|
||||
createNewVisits: (createdVisits: CreateVisit[]) => void;
|
||||
loadMercureInfo: () => void;
|
||||
mercureInfo: MercureInfo;
|
||||
}
|
||||
|
||||
export function boundToMercureHub<T = {}>(
|
||||
WrappedComponent: FC<MercureBoundProps & T>,
|
||||
getTopicsForProps: (props: T, routeParams: any) => string[],
|
||||
) {
|
||||
const pendingUpdates = new Set<CreateVisit>();
|
||||
|
||||
return (props: MercureBoundProps & T) => {
|
||||
const { createNewVisits, loadMercureInfo, mercureInfo } = props;
|
||||
const { interval } = mercureInfo;
|
||||
const params = useParams();
|
||||
|
||||
// Every time mercure info changes, re-bind
|
||||
useEffect(() => {
|
||||
const onMessage = (visit: CreateVisit) => (interval ? pendingUpdates.add(visit) : createNewVisits([visit]));
|
||||
const topics = getTopicsForProps(props, params);
|
||||
const closeEventSource = bindToMercureTopic(mercureInfo, topics, onMessage, loadMercureInfo);
|
||||
|
||||
if (!interval) {
|
||||
return closeEventSource;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
createNewVisits([...pendingUpdates]);
|
||||
pendingUpdates.clear();
|
||||
}, interval * 1000 * 60);
|
||||
|
||||
return pipe(() => clearInterval(timer), () => closeEventSource?.());
|
||||
}, [mercureInfo]);
|
||||
|
||||
return <WrappedComponent {...props} />;
|
||||
};
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
|
||||
import type { MercureInfo } from '../reducers/mercureInfo';
|
||||
|
||||
export const bindToMercureTopic = <T>(mercureInfo: MercureInfo, topics: string[], onMessage: (message: T) => void, onTokenExpired: () => void) => { // eslint-disable-line max-len
|
||||
const { mercureHubUrl, token, loading, error } = mercureInfo;
|
||||
|
||||
if (loading || error || !mercureHubUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const onEventSourceMessage = ({ data }: { data: string }) => onMessage(JSON.parse(data) as T);
|
||||
const onEventSourceError = ({ status }: { status: number }) => status === 401 && onTokenExpired();
|
||||
|
||||
const subscriptions = topics.map((topic) => {
|
||||
const hubUrl = new URL(mercureHubUrl);
|
||||
|
||||
hubUrl.searchParams.append('topic', topic);
|
||||
const es = new EventSource(hubUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
es.onmessage = onEventSourceMessage;
|
||||
es.onerror = onEventSourceError;
|
||||
|
||||
return es;
|
||||
});
|
||||
|
||||
return () => subscriptions.forEach((es) => es.close());
|
||||
};
|
|
@ -1,43 +0,0 @@
|
|||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import type { ShlinkApiClient, ShlinkMercureInfo } from '../../api-contract';
|
||||
import { createAsyncThunk } from '../../utils/redux';
|
||||
import type { Settings } from '../../utils/settings';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/mercure';
|
||||
|
||||
export interface MercureInfo extends Partial<ShlinkMercureInfo> {
|
||||
interval?: number;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
const initialState: MercureInfo = {
|
||||
loading: true,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export const mercureInfoReducerCreator = (apiClientFactory: () => ShlinkApiClient) => {
|
||||
const loadMercureInfo = createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/loadMercureInfo`,
|
||||
({ realTimeUpdates }: Settings): Promise<ShlinkMercureInfo> => {
|
||||
if (realTimeUpdates && !realTimeUpdates.enabled) {
|
||||
throw new Error('Real time updates not enabled');
|
||||
}
|
||||
|
||||
return apiClientFactory().mercureInfo();
|
||||
},
|
||||
);
|
||||
|
||||
const { reducer } = createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(loadMercureInfo.pending, (state) => ({ ...state, loading: true, error: false }));
|
||||
builder.addCase(loadMercureInfo.rejected, (state) => ({ ...state, loading: false, error: true }));
|
||||
builder.addCase(loadMercureInfo.fulfilled, (_, { payload }) => ({ ...payload, loading: false, error: false }));
|
||||
},
|
||||
});
|
||||
|
||||
return { loadMercureInfo, reducer };
|
||||
};
|
|
@ -1,12 +0,0 @@
|
|||
import type Bottle from 'bottlejs';
|
||||
import { prop } from 'ramda';
|
||||
import { mercureInfoReducerCreator } from '../reducers/mercureInfo';
|
||||
|
||||
export const provideServices = (bottle: Bottle) => {
|
||||
// Reducer
|
||||
bottle.serviceFactory('mercureInfoReducerCreator', mercureInfoReducerCreator, 'apiClientFactory');
|
||||
bottle.serviceFactory('mercureInfoReducer', prop('reducer'), 'mercureInfoReducerCreator');
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('loadMercureInfo', prop('loadMercureInfo'), 'mercureInfoReducerCreator');
|
||||
};
|
|
@ -1,112 +0,0 @@
|
|||
import type { FC } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Card, CardBody, CardHeader, Row } from 'reactstrap';
|
||||
import type { ShlinkShortUrlsListParams } from '../api-contract';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
||||
import type { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
||||
import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList';
|
||||
import type { ShortUrlsTableType } from '../short-urls/ShortUrlsTable';
|
||||
import type { TagsList } from '../tags/reducers/tagsList';
|
||||
import { prettify } from '../utils/helpers/numbers';
|
||||
import { useRoutesPrefix } from '../utils/routesPrefix';
|
||||
import { useSetting } from '../utils/settings';
|
||||
import type { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||
import { HighlightCard } from './helpers/HighlightCard';
|
||||
import { VisitsHighlightCard } from './helpers/VisitsHighlightCard';
|
||||
|
||||
interface OverviewConnectProps {
|
||||
shortUrlsList: ShortUrlsListState;
|
||||
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
||||
listTags: Function;
|
||||
tagsList: TagsList;
|
||||
visitsOverview: VisitsOverview;
|
||||
loadVisitsOverview: Function;
|
||||
}
|
||||
|
||||
export const Overview = (
|
||||
ShortUrlsTable: ShortUrlsTableType,
|
||||
CreateShortUrl: FC<CreateShortUrlProps>,
|
||||
) => boundToMercureHub(({
|
||||
shortUrlsList,
|
||||
listShortUrls,
|
||||
listTags,
|
||||
tagsList,
|
||||
loadVisitsOverview,
|
||||
visitsOverview,
|
||||
}: OverviewConnectProps) => {
|
||||
const { loading, shortUrls } = shortUrlsList;
|
||||
const { loading: loadingTags } = tagsList;
|
||||
const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview;
|
||||
const routesPrefix = useRoutesPrefix();
|
||||
const navigate = useNavigate();
|
||||
const visits = useSetting('visits');
|
||||
|
||||
useEffect(() => {
|
||||
listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } });
|
||||
listTags();
|
||||
loadVisitsOverview();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<div className="col-lg-6 col-xl-3 mb-3">
|
||||
<VisitsHighlightCard
|
||||
title="Visits"
|
||||
link={`${routesPrefix}/non-orphan-visits`}
|
||||
excludeBots={visits?.excludeBots ?? false}
|
||||
loading={loadingVisits}
|
||||
visitsSummary={nonOrphanVisits}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-lg-6 col-xl-3 mb-3">
|
||||
<VisitsHighlightCard
|
||||
title="Orphan visits"
|
||||
link={`${routesPrefix}/orphan-visits`}
|
||||
excludeBots={visits?.excludeBots ?? false}
|
||||
loading={loadingVisits}
|
||||
visitsSummary={orphanVisits}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-lg-6 col-xl-3 mb-3">
|
||||
<HighlightCard title="Short URLs" link={`${routesPrefix}/list-short-urls/1`}>
|
||||
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
|
||||
</HighlightCard>
|
||||
</div>
|
||||
<div className="col-lg-6 col-xl-3 mb-3">
|
||||
<HighlightCard title="Tags" link={`${routesPrefix}/manage-tags`}>
|
||||
{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}
|
||||
</HighlightCard>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<Card className="mb-3">
|
||||
<CardHeader>
|
||||
<span className="d-sm-none">Create a short URL</span>
|
||||
<h5 className="d-none d-sm-inline">Create a short URL</h5>
|
||||
<Link className="float-end" to={`${routesPrefix}/create-short-url`}>Advanced options »</Link>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<CreateShortUrl basicMode />
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<span className="d-sm-none">Recently created URLs</span>
|
||||
<h5 className="d-none d-sm-inline">Recently created URLs</h5>
|
||||
<Link className="float-end" to={`${routesPrefix}/list-short-urls/1`}>See all »</Link>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ShortUrlsTable
|
||||
shortUrlsList={shortUrlsList}
|
||||
className="mb-0"
|
||||
onTagClick={(tag) => navigate(`${routesPrefix}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}, () => [Topics.visits, Topics.orphanVisits]);
|
|
@ -1,21 +0,0 @@
|
|||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
|
||||
.highlight-card.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: .1;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.highlight-card__title {
|
||||
text-transform: uppercase;
|
||||
color: $textPlaceholder;
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useElementRef } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardText, CardTitle, UncontrolledTooltip } from 'reactstrap';
|
||||
import './HighlightCard.scss';
|
||||
|
||||
export type HighlightCardProps = PropsWithChildren<{
|
||||
title: string;
|
||||
link: string;
|
||||
tooltip?: ReactNode;
|
||||
}>;
|
||||
|
||||
const buildExtraProps = (link: string) => ({ tag: Link, to: link });
|
||||
|
||||
export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link, tooltip }) => {
|
||||
const ref = useElementRef<HTMLElement>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card innerRef={ref} className="highlight-card" body {...buildExtraProps(link)}>
|
||||
<FontAwesomeIcon size="3x" className="highlight-card__link-icon" icon={linkIcon} />
|
||||
<CardTitle tag="h5" className="highlight-card__title">{title}</CardTitle>
|
||||
<CardText tag="h2">{children}</CardText>
|
||||
</Card>
|
||||
{tooltip && <UncontrolledTooltip target={ref} placement="bottom">{tooltip}</UncontrolledTooltip>}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,26 +0,0 @@
|
|||
import type { FC } from 'react';
|
||||
import { prettify } from '../../utils/helpers/numbers';
|
||||
import type { PartialVisitsSummary } from '../../visits/reducers/visitsOverview';
|
||||
import type { HighlightCardProps } from './HighlightCard';
|
||||
import { HighlightCard } from './HighlightCard';
|
||||
|
||||
export type VisitsHighlightCardProps = Omit<HighlightCardProps, 'tooltip' | 'children'> & {
|
||||
loading: boolean;
|
||||
excludeBots: boolean;
|
||||
visitsSummary: PartialVisitsSummary;
|
||||
};
|
||||
|
||||
export const VisitsHighlightCard: FC<VisitsHighlightCardProps> = ({ loading, excludeBots, visitsSummary, ...rest }) => (
|
||||
<HighlightCard
|
||||
tooltip={
|
||||
visitsSummary.bots !== undefined
|
||||
? <>{excludeBots ? 'Plus' : 'Including'} <strong>{prettify(visitsSummary.bots)}</strong> potential bot visits</>
|
||||
: undefined
|
||||
}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? 'Loading...' : prettify(
|
||||
excludeBots && visitsSummary.nonBots ? visitsSummary.nonBots : visitsSummary.total,
|
||||
)}
|
||||
</HighlightCard>
|
||||
);
|
|
@ -1,11 +0,0 @@
|
|||
import type Bottle from 'bottlejs';
|
||||
import type { ConnectDecorator } from '../../container';
|
||||
import { Overview } from '../Overview';
|
||||
|
||||
export function provideServices(bottle: Bottle, connect: ConnectDecorator) {
|
||||
bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl');
|
||||
bottle.decorator('Overview', connect(
|
||||
['shortUrlsList', 'tagsList', 'mercureInfo', 'visitsOverview'],
|
||||
['listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview'],
|
||||
));
|
||||
}
|
|
@ -1,65 +0,0 @@
|
|||
import type { ShlinkCreateShortUrlData } from '@shlinkio/shlink-web-component/api-contract';
|
||||
import type { FC } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import type { ShortUrlCreationSettings } from '../utils/settings';
|
||||
import { useSetting } from '../utils/settings';
|
||||
import type { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
|
||||
import type { ShortUrlCreation } from './reducers/shortUrlCreation';
|
||||
import type { ShortUrlFormProps } from './ShortUrlForm';
|
||||
|
||||
export interface CreateShortUrlProps {
|
||||
basicMode?: boolean;
|
||||
}
|
||||
|
||||
interface CreateShortUrlConnectProps extends CreateShortUrlProps {
|
||||
shortUrlCreation: ShortUrlCreation;
|
||||
createShortUrl: (data: ShlinkCreateShortUrlData) => Promise<void>;
|
||||
resetCreateShortUrl: () => void;
|
||||
}
|
||||
|
||||
const getInitialState = (settings?: ShortUrlCreationSettings): ShlinkCreateShortUrlData => ({
|
||||
longUrl: '',
|
||||
tags: [],
|
||||
customSlug: '',
|
||||
title: undefined,
|
||||
shortCodeLength: undefined,
|
||||
domain: '',
|
||||
validSince: undefined,
|
||||
validUntil: undefined,
|
||||
maxVisits: undefined,
|
||||
findIfExists: false,
|
||||
validateUrl: settings?.validateUrls ?? false,
|
||||
forwardQuery: settings?.forwardQuery ?? true,
|
||||
});
|
||||
|
||||
export const CreateShortUrl = (
|
||||
ShortUrlForm: FC<ShortUrlFormProps<ShlinkCreateShortUrlData>>,
|
||||
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
|
||||
) => ({
|
||||
createShortUrl,
|
||||
shortUrlCreation,
|
||||
resetCreateShortUrl,
|
||||
basicMode = false,
|
||||
}: CreateShortUrlConnectProps) => {
|
||||
const shortUrlCreationSettings = useSetting('shortUrlCreation');
|
||||
const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [shortUrlCreationSettings]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ShortUrlForm
|
||||
initialState={initialState}
|
||||
saving={shortUrlCreation.saving}
|
||||
mode={basicMode ? 'create-basic' : 'create'}
|
||||
onSave={async (data) => {
|
||||
resetCreateShortUrl();
|
||||
return createShortUrl(data);
|
||||
}}
|
||||
/>
|
||||
<CreateShortUrlResult
|
||||
creation={shortUrlCreation}
|
||||
resetCreateShortUrl={resetCreateShortUrl}
|
||||
canBeClosed={basicMode}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,95 +0,0 @@
|
|||
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Message, parseQuery, Result } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { ShlinkEditShortUrlData } from '@shlinkio/shlink-web-component/api-contract';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Button, Card } from 'reactstrap';
|
||||
import { ShlinkApiError } from '../common/ShlinkApiError';
|
||||
import { useGoBack } from '../utils/helpers/hooks';
|
||||
import { useSetting } from '../utils/settings';
|
||||
import type { ShortUrlIdentifier } from './data';
|
||||
import { shortUrlDataFromShortUrl, urlDecodeShortCode } from './helpers';
|
||||
import type { ShortUrlDetail } from './reducers/shortUrlDetail';
|
||||
import type { EditShortUrl as EditShortUrlInfo, ShortUrlEdition } from './reducers/shortUrlEdition';
|
||||
import type { ShortUrlFormProps } from './ShortUrlForm';
|
||||
|
||||
interface EditShortUrlConnectProps {
|
||||
shortUrlDetail: ShortUrlDetail;
|
||||
shortUrlEdition: ShortUrlEdition;
|
||||
getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void;
|
||||
editShortUrl: (editShortUrl: EditShortUrlInfo) => void;
|
||||
}
|
||||
|
||||
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps<ShlinkEditShortUrlData>>) => ({
|
||||
shortUrlDetail,
|
||||
getShortUrlDetail,
|
||||
shortUrlEdition,
|
||||
editShortUrl,
|
||||
}: EditShortUrlConnectProps) => {
|
||||
const { search } = useLocation();
|
||||
const params = useParams<{ shortCode: string }>();
|
||||
const goBack = useGoBack();
|
||||
const { loading, error, errorData, shortUrl } = shortUrlDetail;
|
||||
const { saving, saved, error: savingError, errorData: savingErrorData } = shortUrlEdition;
|
||||
const { domain } = parseQuery<{ domain?: string }>(search);
|
||||
const shortUrlCreationSettings = useSetting('shortUrlCreation');
|
||||
const initialState = useMemo(
|
||||
() => shortUrlDataFromShortUrl(shortUrl, shortUrlCreationSettings),
|
||||
[shortUrl, shortUrlCreationSettings],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
params.shortCode && getShortUrlDetail({ shortCode: urlDecodeShortCode(params.shortCode), domain });
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <Message loading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Result type="error">
|
||||
<ShlinkApiError errorData={errorData} fallbackMessage="An error occurred while loading short URL detail :(" />
|
||||
</Result>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 me-3" onClick={goBack}>
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
</Button>
|
||||
<span className="text-center">
|
||||
<small>Edit <ExternalLink href={shortUrl?.shortUrl ?? ''} /></small>
|
||||
</span>
|
||||
<span />
|
||||
</h2>
|
||||
</Card>
|
||||
</header>
|
||||
<ShortUrlForm
|
||||
initialState={initialState}
|
||||
saving={saving}
|
||||
mode="edit"
|
||||
onSave={async (shortUrlData) => {
|
||||
if (!shortUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
editShortUrl({ ...shortUrl, data: shortUrlData });
|
||||
}}
|
||||
/>
|
||||
{saved && savingError && (
|
||||
<Result type="error" className="mt-3">
|
||||
<ShlinkApiError errorData={savingErrorData} fallbackMessage="An error occurred while updating short URL :(" />
|
||||
</Result>
|
||||
)}
|
||||
{saved && !savingError && <Result type="success" className="mt-3">Short URL properly edited.</Result>}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,53 +0,0 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||
import type { ShlinkPaginator } from '../api-contract';
|
||||
import type {
|
||||
NumberOrEllipsis } from '../utils/helpers/pagination';
|
||||
import {
|
||||
keyForPage,
|
||||
pageIsEllipsis,
|
||||
prettifyPageNumber,
|
||||
progressivePagination,
|
||||
} from '../utils/helpers/pagination';
|
||||
import { useRoutesPrefix } from '../utils/routesPrefix';
|
||||
|
||||
interface PaginatorProps {
|
||||
paginator?: ShlinkPaginator;
|
||||
currentQueryString?: string;
|
||||
}
|
||||
|
||||
export const Paginator = ({ paginator, currentQueryString = '' }: PaginatorProps) => {
|
||||
const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
|
||||
const routesPrefix = useRoutesPrefix();
|
||||
const urlForPage = (pageNumber: NumberOrEllipsis) =>
|
||||
`${routesPrefix}/list-short-urls/${pageNumber}${currentQueryString}`;
|
||||
|
||||
if (pagesCount <= 1) {
|
||||
return <div className="pb-3" />; // Return some space
|
||||
}
|
||||
|
||||
const renderPages = () =>
|
||||
progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
|
||||
<PaginationItem
|
||||
key={keyForPage(pageNumber, index)}
|
||||
disabled={pageIsEllipsis(pageNumber)}
|
||||
active={currentPage === pageNumber}
|
||||
>
|
||||
<PaginationLink tag={Link} to={urlForPage(pageNumber)}>
|
||||
{prettifyPageNumber(pageNumber)}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
));
|
||||
|
||||
return (
|
||||
<Pagination className="sticky-card-paginator py-3" listClassName="flex-wrap justify-content-center mb-0">
|
||||
<PaginationItem disabled={currentPage === 1}>
|
||||
<PaginationLink previous tag={Link} to={urlForPage(currentPage - 1)} />
|
||||
</PaginationItem>
|
||||
{renderPages()}
|
||||
<PaginationItem disabled={currentPage >= pagesCount}>
|
||||
<PaginationLink next tag={Link} to={urlForPage(currentPage + 1)} />
|
||||
</PaginationItem>
|
||||
</Pagination>
|
||||
);
|
||||
};
|
|
@ -1,9 +0,0 @@
|
|||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
|
||||
.short-url-form p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.short-url-form .card {
|
||||
height: 100%;
|
||||
}
|
|
@ -1,274 +0,0 @@
|
|||
import type { IconProp } from '@fortawesome/fontawesome-svg-core';
|
||||
import { faAndroid, faApple } from '@fortawesome/free-brands-svg-icons';
|
||||
import { faDesktop } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Checkbox, SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
||||
import classNames from 'classnames';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { isEmpty } from 'ramda';
|
||||
import type { ChangeEvent, FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, FormGroup, Input, Row } from 'reactstrap';
|
||||
import type { InputType } from 'reactstrap/types/lib/Input';
|
||||
import type { ShlinkCreateShortUrlData, ShlinkDeviceLongUrls, ShlinkEditShortUrlData } from '../api-contract';
|
||||
import type { DomainSelectorProps } from '../domains/DomainSelector';
|
||||
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||
import { IconInput } from '../utils/components/IconInput';
|
||||
import type { DateTimeInputProps } from '../utils/dates/DateTimeInput';
|
||||
import { DateTimeInput } from '../utils/dates/DateTimeInput';
|
||||
import { formatIsoDate } from '../utils/dates/helpers/date';
|
||||
import { useFeature } from '../utils/features';
|
||||
import { handleEventPreventingDefault, hasValue } from '../utils/helpers';
|
||||
import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup';
|
||||
import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon';
|
||||
import './ShortUrlForm.scss';
|
||||
|
||||
export type Mode = 'create' | 'create-basic' | 'edit';
|
||||
|
||||
type DateFields = 'validSince' | 'validUntil';
|
||||
type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits' | 'title';
|
||||
|
||||
export interface ShortUrlFormProps<T extends ShlinkCreateShortUrlData | ShlinkEditShortUrlData> {
|
||||
// FIXME Try to get rid of the mode param, and infer creation or edition from initialState if possible
|
||||
mode: Mode;
|
||||
saving: boolean;
|
||||
initialState: T;
|
||||
onSave: (shortUrlData: T) => Promise<unknown>;
|
||||
}
|
||||
|
||||
const toDate = (date?: string | Date): Date | undefined => (typeof date === 'string' ? parseISO(date) : date);
|
||||
|
||||
const isCreationData = (data: ShlinkCreateShortUrlData | ShlinkEditShortUrlData): data is ShlinkCreateShortUrlData =>
|
||||
'shortCodeLength' in data && 'customSlug' in data && 'domain' in data;
|
||||
|
||||
export const ShortUrlForm = (
|
||||
TagsSelector: FC<TagsSelectorProps>,
|
||||
DomainSelector: FC<DomainSelectorProps>,
|
||||
) => function ShortUrlFormComp<T extends ShlinkCreateShortUrlData | ShlinkEditShortUrlData>(
|
||||
{ mode, saving, onSave, initialState }: ShortUrlFormProps<T>,
|
||||
) {
|
||||
const [shortUrlData, setShortUrlData] = useState(initialState);
|
||||
const reset = () => setShortUrlData(initialState);
|
||||
const supportsDeviceLongUrls = useFeature('deviceLongUrls');
|
||||
|
||||
const isEdit = mode === 'edit';
|
||||
const isCreation = isCreationData(shortUrlData);
|
||||
const isBasicMode = mode === 'create-basic';
|
||||
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags });
|
||||
const setResettableValue = (value: string, initialValue?: any) => {
|
||||
if (hasValue(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// If an initial value was provided for this when the input is "emptied", explicitly set it to null so that the
|
||||
// value gets removed. Otherwise, set undefined so that it gets ignored.
|
||||
return hasValue(initialValue) ? null : undefined;
|
||||
};
|
||||
const submit = handleEventPreventingDefault(async () => onSave({
|
||||
...shortUrlData,
|
||||
validSince: formatIsoDate(shortUrlData.validSince) ?? null,
|
||||
validUntil: formatIsoDate(shortUrlData.validUntil) ?? null,
|
||||
maxVisits: !hasValue(shortUrlData.maxVisits) ? null : Number(shortUrlData.maxVisits),
|
||||
}).then(() => !isEdit && reset()).catch(() => {}));
|
||||
|
||||
useEffect(() => {
|
||||
setShortUrlData(initialState);
|
||||
}, [initialState]);
|
||||
|
||||
// TODO Consider extracting these functions to local components
|
||||
const renderOptionalInput = (
|
||||
id: NonDateFields,
|
||||
placeholder: string,
|
||||
type: InputType = 'text',
|
||||
props: any = {},
|
||||
fromGroupProps = {},
|
||||
) => (
|
||||
<FormGroup {...fromGroupProps}>
|
||||
<Input
|
||||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
// @ts-expect-error FIXME Make sure id is a key from T
|
||||
value={shortUrlData[id] ?? ''}
|
||||
onChange={props.onChange ?? ((e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value }))}
|
||||
{...props}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
const renderDeviceLongUrlInput = (id: keyof ShlinkDeviceLongUrls, placeholder: string, icon: IconProp) => (
|
||||
<IconInput
|
||||
icon={icon}
|
||||
id={id}
|
||||
type="url"
|
||||
placeholder={placeholder}
|
||||
value={shortUrlData.deviceLongUrls?.[id] ?? ''}
|
||||
onChange={(e) => setShortUrlData({
|
||||
...shortUrlData,
|
||||
deviceLongUrls: {
|
||||
...(shortUrlData.deviceLongUrls ?? {}),
|
||||
[id]: setResettableValue(e.target.value, initialState.deviceLongUrls?.[id]),
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateTimeInputProps> = {}) => (
|
||||
<DateTimeInput
|
||||
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
|
||||
placeholderText={placeholder}
|
||||
isClearable
|
||||
onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
const basicComponents = (
|
||||
<>
|
||||
<FormGroup>
|
||||
<Input
|
||||
bsSize="lg"
|
||||
type="url"
|
||||
placeholder="URL to be shortened"
|
||||
required
|
||||
value={shortUrlData.longUrl}
|
||||
onChange={(e) => setShortUrlData({ ...shortUrlData, longUrl: e.target.value })}
|
||||
/>
|
||||
</FormGroup>
|
||||
<Row>
|
||||
{isBasicMode && renderOptionalInput('customSlug', 'Custom slug', 'text', { bsSize: 'lg' }, { className: 'col-lg-6' })}
|
||||
<div className={isBasicMode ? 'col-lg-6 mb-3' : 'col-12'}>
|
||||
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
|
||||
</div>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<form name="shortUrlForm" className="short-url-form" onSubmit={submit}>
|
||||
{isBasicMode && basicComponents}
|
||||
{!isBasicMode && (
|
||||
<>
|
||||
<Row>
|
||||
<div
|
||||
className={classNames('mb-3', { 'col-sm-6': supportsDeviceLongUrls, 'col-12': !supportsDeviceLongUrls })}
|
||||
>
|
||||
<SimpleCard title="Main options" className="mb-3">
|
||||
{basicComponents}
|
||||
</SimpleCard>
|
||||
</div>
|
||||
{supportsDeviceLongUrls && (
|
||||
<div className="col-sm-6 mb-3">
|
||||
<SimpleCard title="Device-specific long URLs">
|
||||
<FormGroup>
|
||||
{renderDeviceLongUrlInput('android', 'Android-specific redirection', faAndroid)}
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
{renderDeviceLongUrlInput('ios', 'iOS-specific redirection', faApple)}
|
||||
</FormGroup>
|
||||
{renderDeviceLongUrlInput('desktop', 'Desktop-specific redirection', faDesktop)}
|
||||
</SimpleCard>
|
||||
</div>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<div className="col-sm-6 mb-3">
|
||||
<SimpleCard title="Customize the short URL">
|
||||
{renderOptionalInput('title', 'Title', 'text', {
|
||||
onChange: ({ target }: ChangeEvent<HTMLInputElement>) => setShortUrlData({
|
||||
...shortUrlData,
|
||||
title: setResettableValue(target.value, initialState.title),
|
||||
}),
|
||||
})}
|
||||
{!isEdit && isCreation && (
|
||||
<>
|
||||
<Row>
|
||||
<div className="col-lg-6">
|
||||
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
|
||||
disabled: hasValue(shortUrlData.shortCodeLength),
|
||||
})}
|
||||
</div>
|
||||
<div className="col-lg-6">
|
||||
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
||||
min: 4,
|
||||
disabled: hasValue(shortUrlData.customSlug),
|
||||
})}
|
||||
</div>
|
||||
</Row>
|
||||
<DomainSelector
|
||||
value={shortUrlData.domain}
|
||||
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SimpleCard>
|
||||
</div>
|
||||
|
||||
<div className="col-sm-6 mb-3">
|
||||
<SimpleCard title="Limit access to the short URL">
|
||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||
<div className="mb-3">
|
||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? toDate(shortUrlData.validUntil) : undefined })}
|
||||
</div>
|
||||
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? toDate(shortUrlData.validSince) : undefined })}
|
||||
</SimpleCard>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<div className="col-sm-6 mb-3">
|
||||
<SimpleCard title="Extra checks">
|
||||
<ShortUrlFormCheckboxGroup
|
||||
infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible."
|
||||
checked={shortUrlData.validateUrl}
|
||||
onChange={(validateUrl) => setShortUrlData({ ...shortUrlData, validateUrl })}
|
||||
>
|
||||
Validate URL
|
||||
</ShortUrlFormCheckboxGroup>
|
||||
{!isEdit && isCreation && (
|
||||
<p>
|
||||
<Checkbox
|
||||
inline
|
||||
className="me-2"
|
||||
checked={shortUrlData.findIfExists}
|
||||
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
|
||||
>
|
||||
Use existing URL if found
|
||||
</Checkbox>
|
||||
<UseExistingIfFoundInfoIcon />
|
||||
</p>
|
||||
)}
|
||||
</SimpleCard>
|
||||
</div>
|
||||
<div className="col-sm-6 mb-3">
|
||||
<SimpleCard title="Configure behavior">
|
||||
<ShortUrlFormCheckboxGroup
|
||||
infoTooltip="This short URL will be included in the robots.txt for your Shlink instance, allowing web crawlers (like Google) to index it."
|
||||
checked={shortUrlData.crawlable}
|
||||
onChange={(crawlable) => setShortUrlData({ ...shortUrlData, crawlable })}
|
||||
>
|
||||
Make it crawlable
|
||||
</ShortUrlFormCheckboxGroup>
|
||||
<ShortUrlFormCheckboxGroup
|
||||
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
|
||||
checked={shortUrlData.forwardQuery}
|
||||
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
|
||||
>
|
||||
Forward query params on redirect
|
||||
</ShortUrlFormCheckboxGroup>
|
||||
</SimpleCard>
|
||||
</div>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<Button
|
||||
outline
|
||||
color="primary"
|
||||
disabled={saving || isEmpty(shortUrlData.longUrl)}
|
||||
className="btn-xs-block"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
|
@ -1,4 +0,0 @@
|
|||
.short-urls-filtering-bar__tags-icon {
|
||||
vertical-align: bottom;
|
||||
font-size: 1.6rem;
|
||||
}
|
|
@ -1,121 +0,0 @@
|
|||
import { faTag, faTags } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import type { OrderDir } from '@shlinkio/shlink-frontend-kit';
|
||||
import { OrderingDropdown, SearchField } from '@shlinkio/shlink-frontend-kit';
|
||||
import classNames from 'classnames';
|
||||
import { isEmpty, pipe } from 'ramda';
|
||||
import type { FC } from 'react';
|
||||
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
|
||||
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||
import { formatIsoDate } from '../utils/dates/helpers/date';
|
||||
import type { DateRange } from '../utils/dates/helpers/dateIntervals';
|
||||
import { datesToDateRange } from '../utils/dates/helpers/dateIntervals';
|
||||
import { useFeature } from '../utils/features';
|
||||
import { useSetting } from '../utils/settings';
|
||||
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
|
||||
import { SHORT_URLS_ORDERABLE_FIELDS } from './data';
|
||||
import type { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
|
||||
import { useShortUrlsQuery } from './helpers/hooks';
|
||||
import { ShortUrlsFilterDropdown } from './helpers/ShortUrlsFilterDropdown';
|
||||
import './ShortUrlsFilteringBar.scss';
|
||||
|
||||
interface ShortUrlsFilteringProps {
|
||||
order: ShortUrlsOrder;
|
||||
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
|
||||
className?: string;
|
||||
shortUrlsAmount?: number;
|
||||
}
|
||||
|
||||
export const ShortUrlsFilteringBar = (
|
||||
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
|
||||
TagsSelector: FC<TagsSelectorProps>,
|
||||
): FC<ShortUrlsFilteringProps> => ({ className, shortUrlsAmount, order, handleOrderBy }) => {
|
||||
const [filter, toFirstPage] = useShortUrlsQuery();
|
||||
const {
|
||||
search,
|
||||
tags,
|
||||
startDate,
|
||||
endDate,
|
||||
excludeBots,
|
||||
excludeMaxVisitsReached,
|
||||
excludePastValidUntil,
|
||||
tagsMode = 'any',
|
||||
} = filter;
|
||||
const supportsDisabledFiltering = useFeature('filterDisabledUrls');
|
||||
const visitsSettings = useSetting('visits');
|
||||
|
||||
const setDates = pipe(
|
||||
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
|
||||
startDate: formatIsoDate(theStartDate) ?? undefined,
|
||||
endDate: formatIsoDate(theEndDate) ?? undefined,
|
||||
}),
|
||||
toFirstPage,
|
||||
);
|
||||
const setSearch = pipe(
|
||||
(searchTerm: string) => (isEmpty(searchTerm) ? undefined : searchTerm),
|
||||
(searchTerm) => toFirstPage({ search: searchTerm }),
|
||||
);
|
||||
const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags });
|
||||
const toggleTagsMode = pipe(
|
||||
() => (tagsMode === 'any' ? 'all' : 'any'),
|
||||
(mode) => toFirstPage({ tagsMode: mode }),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames('short-urls-filtering-bar-container', className)}>
|
||||
<SearchField initialValue={search} onChange={setSearch} />
|
||||
|
||||
<InputGroup className="mt-3">
|
||||
<TagsSelector allowNew={false} placeholder="With tags..." selectedTags={tags} onChange={changeTagSelection} />
|
||||
{tags.length > 1 && (
|
||||
<>
|
||||
<Button outline color="secondary" onClick={toggleTagsMode} id="tagsModeBtn" aria-label="Change tags mode">
|
||||
<FontAwesomeIcon className="short-urls-filtering-bar__tags-icon" icon={tagsMode === 'all' ? faTags : faTag} />
|
||||
</Button>
|
||||
<UncontrolledTooltip target="tagsModeBtn" placement="left">
|
||||
{tagsMode === 'all' ? 'With all the tags.' : 'With any of the tags.'}
|
||||
</UncontrolledTooltip>
|
||||
</>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
<Row className="flex-lg-row-reverse">
|
||||
<div className="col-lg-8 col-xl-6 mt-3">
|
||||
<div className="d-md-flex">
|
||||
<div className="flex-fill">
|
||||
<DateRangeSelector
|
||||
defaultText="All short URLs"
|
||||
initialDateRange={datesToDateRange(startDate, endDate)}
|
||||
onDatesChange={setDates}
|
||||
/>
|
||||
</div>
|
||||
<ShortUrlsFilterDropdown
|
||||
className="ms-0 ms-md-2 mt-3 mt-md-0"
|
||||
selected={{
|
||||
excludeBots: excludeBots ?? visitsSettings?.excludeBots,
|
||||
excludeMaxVisitsReached,
|
||||
excludePastValidUntil,
|
||||
}}
|
||||
onChange={toFirstPage}
|
||||
supportsDisabledFiltering={supportsDisabledFiltering}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6 col-lg-4 col-xl-6 mt-3">
|
||||
<ExportShortUrlsBtn amount={shortUrlsAmount} />
|
||||
</div>
|
||||
<div className="col-6 d-lg-none mt-3">
|
||||
<OrderingDropdown
|
||||
prefixed={false}
|
||||
items={SHORT_URLS_ORDERABLE_FIELDS}
|
||||
order={order}
|
||||
onChange={handleOrderBy}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ShortUrlsFilteringBarType = ReturnType<typeof ShortUrlsFilteringBar>;
|
|
@ -1,120 +0,0 @@
|
|||
import type { OrderDir } from '@shlinkio/shlink-frontend-kit';
|
||||
import { determineOrderDir } from '@shlinkio/shlink-frontend-kit';
|
||||
import { pipe } from 'ramda';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Card } from 'reactstrap';
|
||||
import type { ShlinkShortUrlsListParams, ShlinkShortUrlsOrder } from '../api-contract';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import { useFeature } from '../utils/features';
|
||||
import { useSettings } from '../utils/settings';
|
||||
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
|
||||
import { useShortUrlsQuery } from './helpers/hooks';
|
||||
import { Paginator } from './Paginator';
|
||||
import type { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||
import type { ShortUrlsFilteringBarType } from './ShortUrlsFilteringBar';
|
||||
import type { ShortUrlsTableType } from './ShortUrlsTable';
|
||||
|
||||
interface ShortUrlsListProps {
|
||||
shortUrlsList: ShortUrlsListState;
|
||||
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = {
|
||||
field: 'dateCreated',
|
||||
dir: 'DESC',
|
||||
};
|
||||
|
||||
export const ShortUrlsList = (
|
||||
ShortUrlsTable: ShortUrlsTableType,
|
||||
ShortUrlsFilteringBar: ShortUrlsFilteringBarType,
|
||||
) => boundToMercureHub(({ listShortUrls, shortUrlsList }: ShortUrlsListProps) => {
|
||||
const { page } = useParams();
|
||||
const location = useLocation();
|
||||
const [filter, toFirstPage] = useShortUrlsQuery();
|
||||
const settings = useSettings();
|
||||
const {
|
||||
tags,
|
||||
search,
|
||||
startDate,
|
||||
endDate,
|
||||
orderBy,
|
||||
tagsMode,
|
||||
excludeBots,
|
||||
excludePastValidUntil,
|
||||
excludeMaxVisitsReached,
|
||||
} = filter;
|
||||
const [actualOrderBy, setActualOrderBy] = useState(
|
||||
// This separated state handling is needed to be able to fall back to settings value, but only once when loaded
|
||||
orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING,
|
||||
);
|
||||
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
||||
const doExcludeBots = excludeBots ?? settings.visits?.excludeBots;
|
||||
const supportsExcludingBots = useFeature('excludeBotsOnShortUrls');
|
||||
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
|
||||
toFirstPage({ orderBy: { field, dir } });
|
||||
setActualOrderBy({ field, dir });
|
||||
};
|
||||
const orderByColumn = (field: ShortUrlsOrderableFields) => () =>
|
||||
handleOrderBy(field, determineOrderDir(field, actualOrderBy.field, actualOrderBy.dir));
|
||||
const renderOrderIcon = (field: ShortUrlsOrderableFields) =>
|
||||
<TableOrderIcon currentOrder={actualOrderBy} field={field} />;
|
||||
const addTag = pipe(
|
||||
(newTag: string) => [...new Set([...tags, newTag])],
|
||||
(updatedTags) => toFirstPage({ tags: updatedTags }),
|
||||
);
|
||||
const parseOrderByForShlink = ({ field, dir }: ShortUrlsOrder): ShlinkShortUrlsOrder => {
|
||||
if (supportsExcludingBots && doExcludeBots && field === 'visits') {
|
||||
return { field: 'nonBotVisits', dir };
|
||||
}
|
||||
|
||||
return { field, dir };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
listShortUrls({
|
||||
page,
|
||||
searchTerm: search,
|
||||
tags,
|
||||
startDate,
|
||||
endDate,
|
||||
orderBy: parseOrderByForShlink(actualOrderBy),
|
||||
tagsMode,
|
||||
excludePastValidUntil,
|
||||
excludeMaxVisitsReached,
|
||||
});
|
||||
}, [
|
||||
page,
|
||||
search,
|
||||
tags,
|
||||
startDate,
|
||||
endDate,
|
||||
actualOrderBy.field,
|
||||
actualOrderBy.dir,
|
||||
tagsMode,
|
||||
excludePastValidUntil,
|
||||
excludeMaxVisitsReached,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ShortUrlsFilteringBar
|
||||
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
|
||||
order={actualOrderBy}
|
||||
handleOrderBy={handleOrderBy}
|
||||
className="mb-3"
|
||||
/>
|
||||
<Card body className="pb-0">
|
||||
<ShortUrlsTable
|
||||
shortUrlsList={shortUrlsList}
|
||||
orderByColumn={orderByColumn}
|
||||
renderOrderIcon={renderOrderIcon}
|
||||
onTagClick={addTag}
|
||||
/>
|
||||
<Paginator paginator={pagination} currentQueryString={location.search} />
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}, () => [Topics.visits]);
|
|
@ -1,7 +0,0 @@
|
|||
.short-urls-table.short-urls-table {
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.short-urls-table__header-cell--with-action {
|
||||
cursor: pointer;
|
||||
}
|
|
@ -1,90 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import { isEmpty } from 'ramda';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ShortUrlsOrderableFields } from './data';
|
||||
import type { ShortUrlsRowType } from './helpers/ShortUrlsRow';
|
||||
import type { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||
import './ShortUrlsTable.scss';
|
||||
|
||||
interface ShortUrlsTableProps {
|
||||
orderByColumn?: (column: ShortUrlsOrderableFields) => () => void;
|
||||
renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode;
|
||||
shortUrlsList: ShortUrlsListState;
|
||||
onTagClick?: (tag: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ShortUrlsTable = (ShortUrlsRow: ShortUrlsRowType) => ({
|
||||
orderByColumn,
|
||||
renderOrderIcon,
|
||||
shortUrlsList,
|
||||
onTagClick,
|
||||
className,
|
||||
}: ShortUrlsTableProps) => {
|
||||
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 responsive-table short-urls-table', className);
|
||||
|
||||
const renderShortUrls = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center table-danger text-dark">
|
||||
Something went wrong while loading short URLs :(
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <tr><td colSpan={6} className="text-center">Loading...</td></tr>;
|
||||
}
|
||||
|
||||
if (!loading && isEmpty(shortUrls?.data)) {
|
||||
return <tr><td colSpan={6} className="text-center">No results found</td></tr>;
|
||||
}
|
||||
|
||||
return shortUrls?.data.map((shortUrl) => (
|
||||
<ShortUrlsRow
|
||||
key={shortUrl.shortUrl}
|
||||
shortUrl={shortUrl}
|
||||
onTagClick={onTagClick}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<table className={tableClasses}>
|
||||
<thead className="responsive-table__header short-urls-table__header">
|
||||
<tr>
|
||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
|
||||
Created at {renderOrderIcon?.('dateCreated')}
|
||||
</th>
|
||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
|
||||
Short URL {renderOrderIcon?.('shortCode')}
|
||||
</th>
|
||||
<th className="short-urls-table__header-cell">
|
||||
<span className={actionableFieldClasses} onClick={orderByColumn?.('title')}>
|
||||
Title {renderOrderIcon?.('title')}
|
||||
</span>
|
||||
/
|
||||
<span className={actionableFieldClasses} onClick={orderByColumn?.('longUrl')}>
|
||||
<span className="indivisible">Long URL</span> {renderOrderIcon?.('longUrl')}
|
||||
</span>
|
||||
</th>
|
||||
<th className="short-urls-table__header-cell">Tags</th>
|
||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
|
||||
<span className="indivisible">Visits {renderOrderIcon?.('visits')}</span>
|
||||
</th>
|
||||
<th className="short-urls-table__header-cell" colSpan={2} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{renderShortUrls()}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
export type ShortUrlsTableType = ReturnType<typeof ShortUrlsTable>;
|
|
@ -1,7 +0,0 @@
|
|||
.use-existing-if-found-info-icon__modal-quote {
|
||||
margin-bottom: 0;
|
||||
padding: 10px 15px;
|
||||
font-size: 17.5px;
|
||||
border-left: 5px solid #eeeeee;
|
||||
background-color: #f9f9f9;
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
||||
import './UseExistingIfFoundInfoIcon.scss';
|
||||
|
||||
const InfoModal = ({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) => (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
|
||||
<ModalHeader toggle={toggle}>Info</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>
|
||||
When the
|
||||
<b><i>"Use existing URL if found"</i></b>
|
||||
checkbox is checked, the server will return an existing short URL if it matches provided params.
|
||||
</p>
|
||||
<p>
|
||||
These are the checks performed by Shlink in order to determine if an existing short URL should be returned:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
When only the long URL is provided: The most recent match will be returned, or a new short URL will be created
|
||||
if none is found.
|
||||
</li>
|
||||
<li>
|
||||
When long URL and custom slug and/or domain are provided: Same as in previous case, but it will try to match
|
||||
the short URL using both the long URL and the slug, the long URL and the domain, or the three of them.
|
||||
<br />
|
||||
If the slug is being used by another long URL, an error will be returned.
|
||||
</li>
|
||||
<li>
|
||||
When other params are provided: Same as in previous cases, but it will try to match existing short URLs with
|
||||
all provided data. If any of them does not match, a new short URL will be created
|
||||
</li>
|
||||
</ul>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export const UseExistingIfFoundInfoIcon = () => {
|
||||
const [isModalOpen, toggleModal] = useToggle();
|
||||
|
||||
return (
|
||||
<>
|
||||
<span title="What does this mean?">
|
||||
<FontAwesomeIcon icon={infoIcon} style={{ cursor: 'pointer' }} onClick={toggleModal} />
|
||||
</span>
|
||||
<InfoModal isOpen={isModalOpen} toggle={toggleModal} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,43 +0,0 @@
|
|||
import type { Order } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { ShlinkShortUrl } from '../../api-contract';
|
||||
import type { OptionalString } from '../../utils/helpers';
|
||||
|
||||
export interface ShortUrlIdentifier {
|
||||
shortCode: string;
|
||||
domain?: OptionalString;
|
||||
}
|
||||
|
||||
export interface ShortUrlModalProps {
|
||||
shortUrl: ShlinkShortUrl;
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
export const SHORT_URLS_ORDERABLE_FIELDS = {
|
||||
dateCreated: 'Created at',
|
||||
shortCode: 'Short URL',
|
||||
longUrl: 'Long URL',
|
||||
title: 'Title',
|
||||
visits: 'Visits',
|
||||
};
|
||||
|
||||
export type ShortUrlsOrderableFields = keyof typeof SHORT_URLS_ORDERABLE_FIELDS;
|
||||
|
||||
export type ShortUrlsOrder = Order<ShortUrlsOrderableFields>;
|
||||
|
||||
export interface ExportableShortUrl {
|
||||
createdAt: string;
|
||||
title: string;
|
||||
shortUrl: string;
|
||||
domain?: string;
|
||||
shortCode: string;
|
||||
longUrl: string;
|
||||
tags: string;
|
||||
visits: number;
|
||||
}
|
||||
|
||||
export interface ShortUrlsFilter {
|
||||
excludeBots?: boolean;
|
||||
excludeMaxVisitsReached?: boolean;
|
||||
excludePastValidUntil?: boolean;
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
.create-short-url-result__copy-btn {
|
||||
margin-left: 10px;
|
||||
vertical-align: inherit;
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
import { faClone as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Result } from '@shlinkio/shlink-frontend-kit';
|
||||
import { useEffect } from 'react';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import { Tooltip } from 'reactstrap';
|
||||
import { ShlinkApiError } from '../../common/ShlinkApiError';
|
||||
import type { TimeoutToggle } from '../../utils/helpers/hooks';
|
||||
import type { ShortUrlCreation } from '../reducers/shortUrlCreation';
|
||||
import './CreateShortUrlResult.scss';
|
||||
|
||||
export interface CreateShortUrlResultProps {
|
||||
creation: ShortUrlCreation;
|
||||
resetCreateShortUrl: () => void;
|
||||
canBeClosed?: boolean;
|
||||
}
|
||||
|
||||
export const CreateShortUrlResult = (useTimeoutToggle: TimeoutToggle) => (
|
||||
{ creation, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps,
|
||||
) => {
|
||||
const [showCopyTooltip, setShowCopyTooltip] = useTimeoutToggle();
|
||||
const { error, saved } = creation;
|
||||
|
||||
useEffect(() => {
|
||||
resetCreateShortUrl();
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Result type="error" className="mt-3">
|
||||
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-end pointer" onClick={resetCreateShortUrl} />}
|
||||
<ShlinkApiError errorData={creation.errorData} fallbackMessage="An error occurred while creating the URL :(" />
|
||||
</Result>
|
||||
);
|
||||
}
|
||||
|
||||
if (!saved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { shortUrl } = creation.result;
|
||||
|
||||
return (
|
||||
<Result type="success" className="mt-3">
|
||||
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-end pointer" onClick={resetCreateShortUrl} />}
|
||||
<span><b>Great!</b> The short URL is <b>{shortUrl}</b></span>
|
||||
|
||||
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
|
||||
<button
|
||||
className="btn btn-light btn-sm create-short-url-result__copy-btn"
|
||||
id="copyBtn"
|
||||
type="button"
|
||||
>
|
||||
<FontAwesomeIcon icon={copyIcon} /> Copy
|
||||
</button>
|
||||
</CopyToClipboard>
|
||||
|
||||
<Tooltip placement="left" isOpen={showCopyTooltip} target="copyBtn">
|
||||
Copied!
|
||||
</Tooltip>
|
||||
</Result>
|
||||
);
|
||||
};
|
|
@ -1,75 +0,0 @@
|
|||
import { Result } from '@shlinkio/shlink-frontend-kit';
|
||||
import { pipe } from 'ramda';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { isInvalidDeletionError } from '../../api-contract/utils';
|
||||
import { ShlinkApiError } from '../../common/ShlinkApiError';
|
||||
import { handleEventPreventingDefault } from '../../utils/helpers';
|
||||
import type { ShortUrlIdentifier, ShortUrlModalProps } from '../data';
|
||||
import type { ShortUrlDeletion } from '../reducers/shortUrlDeletion';
|
||||
|
||||
interface DeleteShortUrlModalConnectProps extends ShortUrlModalProps {
|
||||
shortUrlDeletion: ShortUrlDeletion;
|
||||
deleteShortUrl: (shortUrl: ShortUrlIdentifier) => Promise<void>;
|
||||
shortUrlDeleted: (shortUrl: ShortUrlIdentifier) => void;
|
||||
resetDeleteShortUrl: () => void;
|
||||
}
|
||||
|
||||
const DELETION_PATTERN = 'delete';
|
||||
|
||||
export const DeleteShortUrlModal = ({
|
||||
shortUrl,
|
||||
toggle,
|
||||
isOpen,
|
||||
shortUrlDeletion,
|
||||
resetDeleteShortUrl,
|
||||
deleteShortUrl,
|
||||
shortUrlDeleted,
|
||||
}: DeleteShortUrlModalConnectProps) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
useEffect(() => resetDeleteShortUrl, []);
|
||||
|
||||
const { loading, error, deleted, errorData } = shortUrlDeletion;
|
||||
const close = pipe(resetDeleteShortUrl, toggle);
|
||||
const handleDeleteUrl = handleEventPreventingDefault(() => deleteShortUrl(shortUrl).then(toggle));
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={close} centered onClosed={() => deleted && shortUrlDeleted(shortUrl)}>
|
||||
<form onSubmit={handleDeleteUrl}>
|
||||
<ModalHeader toggle={close}>
|
||||
<span className="text-danger">Delete short URL</span>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
|
||||
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
|
||||
<p>Write <b>{DELETION_PATTERN}</b> to confirm deletion.</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={`Insert ${DELETION_PATTERN}`}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Result type={isInvalidDeletionError(errorData) ? 'warning' : 'error'} small className="mt-2">
|
||||
<ShlinkApiError errorData={errorData} fallbackMessage="Something went wrong while deleting the URL :(" />
|
||||
</Result>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button type="button" className="btn btn-link" onClick={close}>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-danger"
|
||||
disabled={inputValue !== DELETION_PATTERN || loading}
|
||||
>
|
||||
{loading ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
|
@ -1,58 +0,0 @@
|
|||
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import type { ShlinkApiClient, ShlinkShortUrl } from '../../api-contract';
|
||||
import { ExportBtn } from '../../utils/components/ExportBtn';
|
||||
import type { ReportExporter } from '../../utils/services/ReportExporter';
|
||||
import { useShortUrlsQuery } from './hooks';
|
||||
|
||||
export interface ExportShortUrlsBtnProps {
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
const itemsPerPage = 20;
|
||||
|
||||
export const ExportShortUrlsBtn = (
|
||||
apiClientFactory: () => ShlinkApiClient,
|
||||
{ exportShortUrls }: ReportExporter,
|
||||
): FC<ExportShortUrlsBtnProps> => ({ amount = 0 }) => {
|
||||
const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery();
|
||||
const [loading,, startLoading, stopLoading] = useToggle();
|
||||
const exportAllUrls = useCallback(async () => {
|
||||
const totalPages = amount / itemsPerPage;
|
||||
const loadAllUrls = async (page = 1): Promise<ShlinkShortUrl[]> => {
|
||||
const { data } = await apiClientFactory().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) => {
|
||||
const { hostname: domain, pathname } = new URL(shortUrl.shortUrl);
|
||||
const shortCode = pathname.substring(1); // Remove trailing slash
|
||||
|
||||
return {
|
||||
createdAt: shortUrl.dateCreated,
|
||||
domain,
|
||||
shortCode,
|
||||
shortUrl: shortUrl.shortUrl,
|
||||
longUrl: shortUrl.longUrl,
|
||||
title: shortUrl.title ?? '',
|
||||
tags: shortUrl.tags.join('|'),
|
||||
visits: shortUrl?.visitsSummary?.total ?? shortUrl.visitsCount,
|
||||
};
|
||||
}));
|
||||
stopLoading();
|
||||
}, []);
|
||||
|
||||
return <ExportBtn loading={loading} className="btn-md-block" amount={amount} onClick={exportAllUrls} />;
|
||||
};
|
|
@ -1,4 +0,0 @@
|
|||
.qr-code-modal__img {
|
||||
max-width: 100%;
|
||||
box-shadow: 0 0 .25rem rgb(0 0 0 / .2);
|
||||
}
|
|
@ -1,95 +0,0 @@
|
|||
import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { Button, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
|
||||
import { CopyToClipboardIcon } from '../../utils/components/CopyToClipboardIcon';
|
||||
import type { QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
|
||||
import { buildQrCodeUrl } from '../../utils/helpers/qrCodes';
|
||||
import type { ImageDownloader } from '../../utils/services/ImageDownloader';
|
||||
import type { ShortUrlModalProps } from '../data';
|
||||
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
|
||||
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
|
||||
import './QrCodeModal.scss';
|
||||
|
||||
export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
||||
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen }: ShortUrlModalProps,
|
||||
) => {
|
||||
const [size, setSize] = useState(300);
|
||||
const [margin, setMargin] = useState(0);
|
||||
const [format, setFormat] = useState<QrCodeFormat>('png');
|
||||
const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L');
|
||||
const qrCodeUrl = useMemo(
|
||||
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }),
|
||||
[shortUrl, size, format, margin, errorCorrection],
|
||||
);
|
||||
const totalSize = useMemo(() => size + margin, [size, margin]);
|
||||
const modalSize = useMemo(() => {
|
||||
if (totalSize < 500) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return totalSize < 800 ? 'lg' : 'xl';
|
||||
}, [totalSize]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered size={modalSize}>
|
||||
<ModalHeader toggle={toggle}>
|
||||
QR code for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<Row>
|
||||
<FormGroup className="d-grid col-md-6">
|
||||
<label>Size: {size}px</label>
|
||||
<input
|
||||
type="range"
|
||||
className="form-control-range"
|
||||
value={size}
|
||||
step={10}
|
||||
min={50}
|
||||
max={1000}
|
||||
onChange={(e) => setSize(Number(e.target.value))}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup className="d-grid col-md-6">
|
||||
<label htmlFor="marginControl">Margin: {margin}px</label>
|
||||
<input
|
||||
id="marginControl"
|
||||
type="range"
|
||||
className="form-control-range"
|
||||
value={margin}
|
||||
step={1}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => setMargin(Number(e.target.value))}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup className="d-grid col-md-6">
|
||||
<QrFormatDropdown format={format} setFormat={setFormat} />
|
||||
</FormGroup>
|
||||
<FormGroup className="col-md-6">
|
||||
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} setErrorCorrection={setErrorCorrection} />
|
||||
</FormGroup>
|
||||
</Row>
|
||||
<div className="text-center">
|
||||
<div className="mb-3">
|
||||
<ExternalLink href={qrCodeUrl} />
|
||||
<CopyToClipboardIcon text={qrCodeUrl} />
|
||||
</div>
|
||||
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`).catch(() => {});
|
||||
}}
|
||||
>
|
||||
Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
);
|
||||
};
|
|
@ -1,29 +0,0 @@
|
|||
import type { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ShlinkShortUrl } from '../../api-contract';
|
||||
import { useRoutesPrefix } from '../../utils/routesPrefix';
|
||||
import { urlEncodeShortCode } from './index';
|
||||
|
||||
export type LinkSuffix = 'visits' | 'edit';
|
||||
|
||||
export interface ShortUrlDetailLinkProps {
|
||||
shortUrl?: ShlinkShortUrl | null;
|
||||
suffix: LinkSuffix;
|
||||
asLink?: boolean;
|
||||
}
|
||||
|
||||
const buildUrl = (routePrefix: string, { shortCode, domain }: ShlinkShortUrl, suffix: LinkSuffix) => {
|
||||
const query = domain ? `?domain=${domain}` : '';
|
||||
return `${routePrefix}/short-code/${urlEncodeShortCode(shortCode)}/${suffix}${query}`;
|
||||
};
|
||||
|
||||
export const ShortUrlDetailLink: FC<ShortUrlDetailLinkProps & Record<string | number, any>> = (
|
||||
{ shortUrl, suffix, asLink, children, ...rest },
|
||||
) => {
|
||||
const routePrefix = useRoutesPrefix();
|
||||
if (!asLink || !shortUrl) {
|
||||
return <span {...rest}>{children}</span>;
|
||||
}
|
||||
|
||||
return <Link to={buildUrl(routePrefix, shortUrl, suffix)} {...rest}>{children}</Link>;
|
||||
};
|
|
@ -1,20 +0,0 @@
|
|||
import { Checkbox } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { ChangeEvent, FC, PropsWithChildren } from 'react';
|
||||
import { InfoTooltip } from '../../utils/components/InfoTooltip';
|
||||
|
||||
type ShortUrlFormCheckboxGroupProps = PropsWithChildren<{
|
||||
checked?: boolean;
|
||||
onChange?: (checked: boolean, e: ChangeEvent<HTMLInputElement>) => void;
|
||||
infoTooltip?: string;
|
||||
}>;
|
||||
|
||||
export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
|
||||
{ children, infoTooltip, checked, onChange },
|
||||
) => (
|
||||
<p>
|
||||
<Checkbox inline checked={checked} className={infoTooltip ? 'me-2' : ''} onChange={onChange}>
|
||||
{children}
|
||||
</Checkbox>
|
||||
{infoTooltip && <InfoTooltip placement="right">{infoTooltip}</InfoTooltip>}
|
||||
</p>
|
||||
);
|
|
@ -1,86 +0,0 @@
|
|||
import type { IconDefinition } from '@fortawesome/fontawesome-common-types';
|
||||
import { faCalendarXmark, faCheck, faLinkSlash } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useElementRef } from '@shlinkio/shlink-frontend-kit';
|
||||
import { isBefore } from 'date-fns';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import type { ShlinkShortUrl } from '../../api-contract';
|
||||
import { formatHumanFriendly, now, parseISO } from '../../utils/dates/helpers/date';
|
||||
|
||||
interface ShortUrlStatusProps {
|
||||
shortUrl: ShlinkShortUrl;
|
||||
}
|
||||
|
||||
interface StatusResult {
|
||||
icon: IconDefinition;
|
||||
className: string;
|
||||
description: ReactNode;
|
||||
}
|
||||
|
||||
const resolveShortUrlStatus = (shortUrl: ShlinkShortUrl): StatusResult => {
|
||||
const { meta, visitsCount, visitsSummary } = shortUrl;
|
||||
const { maxVisits, validSince, validUntil } = meta;
|
||||
const totalVisits = visitsSummary?.total ?? visitsCount;
|
||||
|
||||
if (maxVisits && totalVisits >= maxVisits) {
|
||||
return {
|
||||
icon: faLinkSlash,
|
||||
className: 'text-danger',
|
||||
description: (
|
||||
<>
|
||||
This short URL cannot be currently visited because it has reached the maximum
|
||||
amount of <b>{maxVisits}</b> visit{maxVisits > 1 ? 's' : ''}.
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (validUntil && isBefore(parseISO(validUntil), now())) {
|
||||
return {
|
||||
icon: faCalendarXmark,
|
||||
className: 'text-danger',
|
||||
description: (
|
||||
<>
|
||||
This short URL cannot be visited
|
||||
since <b className="indivisible">{formatHumanFriendly(parseISO(validUntil))}</b>.
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (validSince && isBefore(now(), parseISO(validSince))) {
|
||||
return {
|
||||
icon: faCalendarXmark,
|
||||
className: 'text-warning',
|
||||
description: (
|
||||
<>
|
||||
This short URL will start working
|
||||
on <b className="indivisible">{formatHumanFriendly(parseISO(validSince))}</b>.
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
icon: faCheck,
|
||||
className: 'text-primary',
|
||||
description: 'This short URL can be visited normally.',
|
||||
};
|
||||
};
|
||||
|
||||
export const ShortUrlStatus: FC<ShortUrlStatusProps> = ({ shortUrl }) => {
|
||||
const tooltipRef = useElementRef<HTMLElement>();
|
||||
const { icon, className, description } = resolveShortUrlStatus(shortUrl);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span style={{ cursor: !description ? undefined : 'help' }} ref={tooltipRef}>
|
||||
<FontAwesomeIcon icon={icon} className={className} />
|
||||
</span>
|
||||
<UncontrolledTooltip target={tooltipRef} placement="bottom">
|
||||
{description}
|
||||
</UncontrolledTooltip>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,16 +0,0 @@
|
|||
.short-urls-visits-count__max-visits-control {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.short-url-visits-count__amount {
|
||||
transition: transform .3s ease;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.short-url-visits-count__amount--big {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
.short-url-visits-count__tooltip-list-item:not(:last-child) {
|
||||
margin-bottom: .5rem;
|
||||
}
|
|
@ -1,74 +0,0 @@
|
|||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useElementRef } from '@shlinkio/shlink-frontend-kit';
|
||||
import classNames from 'classnames';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import type { ShlinkShortUrl } from '../../api-contract';
|
||||
import { formatHumanFriendly, parseISO } from '../../utils/dates/helpers/date';
|
||||
import { prettify } from '../../utils/helpers/numbers';
|
||||
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
|
||||
import './ShortUrlVisitsCount.scss';
|
||||
|
||||
interface ShortUrlVisitsCountProps {
|
||||
shortUrl?: ShlinkShortUrl | null;
|
||||
visitsCount: number;
|
||||
active?: boolean;
|
||||
asLink?: boolean;
|
||||
}
|
||||
|
||||
export const ShortUrlVisitsCount = (
|
||||
{ visitsCount, shortUrl, active = false, asLink = false }: ShortUrlVisitsCountProps,
|
||||
) => {
|
||||
const { maxVisits, validSince, validUntil } = shortUrl?.meta ?? {};
|
||||
const hasLimit = !!maxVisits || !!validSince || !!validUntil;
|
||||
const visitsLink = (
|
||||
<ShortUrlDetailLink shortUrl={shortUrl} suffix="visits" asLink={asLink}>
|
||||
<strong
|
||||
className={classNames('short-url-visits-count__amount', { 'short-url-visits-count__amount--big': active })}
|
||||
>
|
||||
{prettify(visitsCount)}
|
||||
</strong>
|
||||
</ShortUrlDetailLink>
|
||||
);
|
||||
|
||||
if (!hasLimit) {
|
||||
return visitsLink;
|
||||
}
|
||||
|
||||
const tooltipRef = useElementRef<HTMLElement>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="indivisible">
|
||||
{visitsLink}
|
||||
<small className="short-urls-visits-count__max-visits-control" ref={tooltipRef}>
|
||||
{maxVisits && <> / {prettify(maxVisits)}</>}
|
||||
<sup className="ms-1">
|
||||
<FontAwesomeIcon icon={infoIcon} />
|
||||
</sup>
|
||||
</small>
|
||||
</span>
|
||||
<UncontrolledTooltip target={tooltipRef} placement="bottom">
|
||||
<ul className="list-unstyled mb-0">
|
||||
{maxVisits && (
|
||||
<li className="short-url-visits-count__tooltip-list-item">
|
||||
This short URL will not accept more than <b>{prettify(maxVisits)}</b> visit{maxVisits === 1 ? '' : 's'}.
|
||||
</li>
|
||||
)}
|
||||
{validSince && (
|
||||
<li className="short-url-visits-count__tooltip-list-item">
|
||||
This short URL will not accept visits
|
||||
before <b className="indivisible">{formatHumanFriendly(parseISO(validSince))}</b>.
|
||||
</li>
|
||||
)}
|
||||
{validUntil && (
|
||||
<li className="short-url-visits-count__tooltip-list-item">
|
||||
This short URL will not accept visits
|
||||
after <b className="indivisible">{formatHumanFriendly(parseISO(validUntil))}</b>.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</UncontrolledTooltip>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,46 +0,0 @@
|
|||
import { DropdownBtn } from '@shlinkio/shlink-frontend-kit';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { hasValue } from '../../utils/helpers';
|
||||
import type { ShortUrlsFilter } from '../data';
|
||||
|
||||
interface ShortUrlsFilterDropdownProps {
|
||||
onChange: (filters: ShortUrlsFilter) => void;
|
||||
supportsDisabledFiltering: boolean;
|
||||
selected?: ShortUrlsFilter;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ShortUrlsFilterDropdown = (
|
||||
{ onChange, selected = {}, className, supportsDisabledFiltering }: ShortUrlsFilterDropdownProps,
|
||||
) => {
|
||||
const { excludeBots = false, excludeMaxVisitsReached = false, excludePastValidUntil = false } = selected;
|
||||
const onFilterClick = (key: keyof ShortUrlsFilter) => () => onChange({ ...selected, [key]: !selected?.[key] });
|
||||
|
||||
return (
|
||||
<DropdownBtn text="Filters" dropdownClassName={className} inline end minWidth={250}>
|
||||
<DropdownItem header>Visits:</DropdownItem>
|
||||
<DropdownItem active={excludeBots} onClick={onFilterClick('excludeBots')}>Ignore visits from bots</DropdownItem>
|
||||
|
||||
{supportsDisabledFiltering && (
|
||||
<>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem header>Short URLs:</DropdownItem>
|
||||
<DropdownItem active={excludeMaxVisitsReached} onClick={onFilterClick('excludeMaxVisitsReached')}>
|
||||
Exclude with visits reached
|
||||
</DropdownItem>
|
||||
<DropdownItem active={excludePastValidUntil} onClick={onFilterClick('excludePastValidUntil')}>
|
||||
Exclude enabled in the past
|
||||
</DropdownItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownItem divider />
|
||||
<DropdownItem
|
||||
disabled={!hasValue(selected)}
|
||||
onClick={() => onChange({ excludeBots: false, excludeMaxVisitsReached: false, excludePastValidUntil: false })}
|
||||
>
|
||||
<i>Clear filters</i>
|
||||
</DropdownItem>
|
||||
</DropdownBtn>
|
||||
);
|
||||
};
|
|
@ -1,46 +0,0 @@
|
|||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
@import '../../utils/mixins/vertical-align';
|
||||
|
||||
@mixin text-ellipsis() {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.short-urls-row__cell.short-urls-row__cell {
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
.short-urls-row__cell--break {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.short-urls-row__cell--indivisible {
|
||||
@media (min-width: $lgMin) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.short-urls-row__short-url-wrapper {
|
||||
@media (max-width: $mdMax) {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (min-width: $lgMin) {
|
||||
@include text-ellipsis();
|
||||
|
||||
vertical-align: bottom;
|
||||
display: inline-block;
|
||||
max-width: 18rem;
|
||||
}
|
||||
}
|
||||
|
||||
.short-urls-row__copy-hint {
|
||||
@include vertical-align(translateX(10px));
|
||||
|
||||
box-shadow: 0 3px 15px rgb(0 0 0 / .25);
|
||||
|
||||
@media (max-width: $responsiveTableBreakpoint) {
|
||||
@include vertical-align(translateX(calc(-100% - 20px)));
|
||||
}
|
||||
}
|
|
@ -1,89 +0,0 @@
|
|||
import type { FC } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import type { ShlinkShortUrl } from '../../api-contract';
|
||||
import { CopyToClipboardIcon } from '../../utils/components/CopyToClipboardIcon';
|
||||
import { Time } from '../../utils/dates/Time';
|
||||
import type { TimeoutToggle } from '../../utils/helpers/hooks';
|
||||
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||
import { useSetting } from '../../utils/settings';
|
||||
import { useShortUrlsQuery } from './hooks';
|
||||
import type { ShortUrlsRowMenuType } from './ShortUrlsRowMenu';
|
||||
import { ShortUrlStatus } from './ShortUrlStatus';
|
||||
import { ShortUrlVisitsCount } from './ShortUrlVisitsCount';
|
||||
import { Tags } from './Tags';
|
||||
import './ShortUrlsRow.scss';
|
||||
|
||||
interface ShortUrlsRowProps {
|
||||
onTagClick?: (tag: string) => void;
|
||||
shortUrl: ShlinkShortUrl;
|
||||
}
|
||||
|
||||
export type ShortUrlsRowType = FC<ShortUrlsRowProps>;
|
||||
|
||||
export const ShortUrlsRow = (
|
||||
ShortUrlsRowMenu: ShortUrlsRowMenuType,
|
||||
colorGenerator: ColorGenerator,
|
||||
useTimeoutToggle: TimeoutToggle,
|
||||
) => ({ shortUrl, onTagClick }: ShortUrlsRowProps) => {
|
||||
const [copiedToClipboard, setCopiedToClipboard] = useTimeoutToggle();
|
||||
const [active, setActive] = useTimeoutToggle(false, 500);
|
||||
const isFirstRun = useRef(true);
|
||||
const [{ excludeBots }] = useShortUrlsQuery();
|
||||
const visits = useSetting('visits');
|
||||
const doExcludeBots = excludeBots ?? visits?.excludeBots;
|
||||
|
||||
useEffect(() => {
|
||||
!isFirstRun.current && setActive();
|
||||
isFirstRun.current = false;
|
||||
}, [shortUrl.visitsSummary?.total, shortUrl.visitsSummary?.nonBots, shortUrl.visitsCount]);
|
||||
|
||||
return (
|
||||
<tr className="responsive-table__row">
|
||||
<td className="indivisible short-urls-row__cell responsive-table__cell" data-th="Created at">
|
||||
<Time date={shortUrl.dateCreated} />
|
||||
</td>
|
||||
<td className="responsive-table__cell short-urls-row__cell" data-th="Short URL">
|
||||
<span className="position-relative short-urls-row__cell--indivisible">
|
||||
<span className="short-urls-row__short-url-wrapper">
|
||||
<ExternalLink href={shortUrl.shortUrl} />
|
||||
</span>
|
||||
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
|
||||
<span className="badge bg-warning text-black short-urls-row__copy-hint" hidden={!copiedToClipboard}>
|
||||
Copied short URL!
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className="responsive-table__cell short-urls-row__cell short-urls-row__cell--break"
|
||||
data-th={`${shortUrl.title ? 'Title' : 'Long URL'}`}
|
||||
>
|
||||
<ExternalLink href={shortUrl.longUrl}>{shortUrl.title ?? shortUrl.longUrl}</ExternalLink>
|
||||
</td>
|
||||
{shortUrl.title && (
|
||||
<td className="short-urls-row__cell responsive-table__cell short-urls-row__cell--break d-lg-none" data-th="Long URL">
|
||||
<ExternalLink href={shortUrl.longUrl} />
|
||||
</td>
|
||||
)}
|
||||
<td className="responsive-table__cell short-urls-row__cell" data-th="Tags">
|
||||
<Tags tags={shortUrl.tags} colorGenerator={colorGenerator} onTagClick={onTagClick} />
|
||||
</td>
|
||||
<td className="responsive-table__cell short-urls-row__cell text-lg-end" data-th="Visits">
|
||||
<ShortUrlVisitsCount
|
||||
visitsCount={(
|
||||
doExcludeBots ? shortUrl.visitsSummary?.nonBots : shortUrl.visitsSummary?.total
|
||||
) ?? shortUrl.visitsCount}
|
||||
shortUrl={shortUrl}
|
||||
active={active}
|
||||
asLink
|
||||
/>
|
||||
</td>
|
||||
<td className="responsive-table__cell short-urls-row__cell" data-th="Status">
|
||||
<ShortUrlStatus shortUrl={shortUrl} />
|
||||
</td>
|
||||
<td className="responsive-table__cell short-urls-row__cell text-end">
|
||||
<ShortUrlsRowMenu shortUrl={shortUrl} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
|
@ -1,52 +0,0 @@
|
|||
import {
|
||||
faChartPie as pieChartIcon,
|
||||
faEdit as editIcon,
|
||||
faMinusCircle as deleteIcon,
|
||||
faQrcode as qrIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { RowDropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import type { ShlinkShortUrl } from '../../api-contract';
|
||||
import type { ShortUrlModalProps } from '../data';
|
||||
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
|
||||
|
||||
interface ShortUrlsRowMenuProps {
|
||||
shortUrl: ShlinkShortUrl;
|
||||
}
|
||||
type ShortUrlModal = FC<ShortUrlModalProps>;
|
||||
|
||||
export const ShortUrlsRowMenu = (
|
||||
DeleteShortUrlModal: ShortUrlModal,
|
||||
QrCodeModal: ShortUrlModal,
|
||||
) => ({ shortUrl }: ShortUrlsRowMenuProps) => {
|
||||
const [isQrModalOpen,, openQrCodeModal, closeQrCodeModal] = useToggle();
|
||||
const [isDeleteModalOpen,, openDeleteModal, closeDeleteModal] = useToggle();
|
||||
|
||||
return (
|
||||
<RowDropdownBtn minWidth={190}>
|
||||
<DropdownItem tag={ShortUrlDetailLink} shortUrl={shortUrl} suffix="visits" asLink>
|
||||
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem tag={ShortUrlDetailLink} shortUrl={shortUrl} suffix="edit" asLink>
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem onClick={openQrCodeModal}>
|
||||
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
||||
</DropdownItem>
|
||||
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={closeQrCodeModal} />
|
||||
|
||||
<DropdownItem divider />
|
||||
|
||||
<DropdownItem className="dropdown-item--danger" onClick={openDeleteModal}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||
</DropdownItem>
|
||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={closeDeleteModal} />
|
||||
</RowDropdownBtn>
|
||||
);
|
||||
};
|
||||
|
||||
export type ShortUrlsRowMenuType = ReturnType<typeof ShortUrlsRowMenu>;
|
|
@ -1,29 +0,0 @@
|
|||
import { isEmpty } from 'ramda';
|
||||
import type { FC } from 'react';
|
||||
import { Tag } from '../../tags/helpers/Tag';
|
||||
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||
|
||||
interface TagsProps {
|
||||
tags: string[];
|
||||
onTagClick?: (tag: string) => void;
|
||||
colorGenerator: ColorGenerator;
|
||||
}
|
||||
|
||||
export const Tags: FC<TagsProps> = ({ tags, onTagClick, colorGenerator }) => {
|
||||
if (isEmpty(tags)) {
|
||||
return <i className="indivisible"><small>No tags</small></i>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{tags.map((tag) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
text={tag}
|
||||
colorGenerator={colorGenerator}
|
||||
onClick={() => onTagClick?.(tag)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,77 +0,0 @@
|
|||
import { orderToString, parseQuery, stringifyQuery, stringToOrder } from '@shlinkio/shlink-frontend-kit';
|
||||
import { isEmpty, pipe } from 'ramda';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import type { TagsFilteringMode } from '../../api-contract';
|
||||
import type { BooleanString } from '../../utils/helpers';
|
||||
import { parseOptionalBooleanToString } from '../../utils/helpers';
|
||||
import { useRoutesPrefix } from '../../utils/routesPrefix';
|
||||
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
|
||||
|
||||
interface ShortUrlsQueryCommon {
|
||||
search?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
tagsMode?: TagsFilteringMode;
|
||||
}
|
||||
|
||||
interface ShortUrlsQuery extends ShortUrlsQueryCommon {
|
||||
orderBy?: string;
|
||||
tags?: string;
|
||||
excludeBots?: BooleanString;
|
||||
excludeMaxVisitsReached?: BooleanString;
|
||||
excludePastValidUntil?: BooleanString;
|
||||
}
|
||||
|
||||
interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
|
||||
orderBy?: ShortUrlsOrder;
|
||||
tags: string[];
|
||||
excludeBots?: boolean;
|
||||
excludeMaxVisitsReached?: boolean;
|
||||
excludePastValidUntil?: boolean;
|
||||
}
|
||||
|
||||
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
|
||||
|
||||
export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
|
||||
const navigate = useNavigate();
|
||||
const { search } = useLocation();
|
||||
const routesPrefix = useRoutesPrefix();
|
||||
|
||||
const filtering = useMemo(
|
||||
pipe(
|
||||
() => parseQuery<ShortUrlsQuery>(search),
|
||||
({ orderBy, tags, excludeBots, excludeMaxVisitsReached, excludePastValidUntil, ...rest }): ShortUrlsFiltering => {
|
||||
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
|
||||
const parsedTags = tags?.split(',') ?? [];
|
||||
return {
|
||||
...rest,
|
||||
orderBy: parsedOrderBy,
|
||||
tags: parsedTags,
|
||||
excludeBots: excludeBots !== undefined ? excludeBots === 'true' : undefined,
|
||||
excludeMaxVisitsReached: excludeMaxVisitsReached !== undefined ? excludeMaxVisitsReached === 'true' : undefined,
|
||||
excludePastValidUntil: excludePastValidUntil !== undefined ? excludePastValidUntil === 'true' : undefined,
|
||||
};
|
||||
},
|
||||
),
|
||||
[search],
|
||||
);
|
||||
const toFirstPageWithExtra = useCallback((extra: Partial<ShortUrlsFiltering>) => {
|
||||
const merged = { ...filtering, ...extra };
|
||||
const { orderBy, tags, excludeBots, excludeMaxVisitsReached, excludePastValidUntil, ...mergedFiltering } = merged;
|
||||
const query: ShortUrlsQuery = {
|
||||
...mergedFiltering,
|
||||
orderBy: orderBy && orderToString(orderBy),
|
||||
tags: tags.length > 0 ? tags.join(',') : undefined,
|
||||
excludeBots: parseOptionalBooleanToString(excludeBots),
|
||||
excludeMaxVisitsReached: parseOptionalBooleanToString(excludeMaxVisitsReached),
|
||||
excludePastValidUntil: parseOptionalBooleanToString(excludePastValidUntil),
|
||||
};
|
||||
const stringifiedQuery = stringifyQuery(query);
|
||||
const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`;
|
||||
|
||||
navigate(`${routesPrefix}/list-short-urls/1${queryString}`);
|
||||
}, [filtering, navigate, routesPrefix]);
|
||||
|
||||
return [filtering, toFirstPageWithExtra];
|
||||
};
|
|
@ -1,57 +0,0 @@
|
|||
import { isNil } from 'ramda';
|
||||
import type { ShlinkCreateShortUrlData, ShlinkShortUrl } from '../../api-contract';
|
||||
import type { OptionalString } from '../../utils/helpers';
|
||||
import type { ShortUrlCreationSettings } from '../../utils/settings';
|
||||
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
|
||||
|
||||
export const shortUrlMatches = (shortUrl: ShlinkShortUrl, shortCode: string, domain: OptionalString): boolean => {
|
||||
if (isNil(domain)) {
|
||||
return shortUrl.shortCode === shortCode && !shortUrl.domain;
|
||||
}
|
||||
|
||||
return shortUrl.shortCode === shortCode && shortUrl.domain === domain;
|
||||
};
|
||||
|
||||
export const domainMatches = (shortUrl: ShlinkShortUrl, domain: string): boolean => {
|
||||
if (!shortUrl.domain && domain === DEFAULT_DOMAIN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return shortUrl.domain === domain;
|
||||
};
|
||||
|
||||
// FIXME This should return ShlinkEditShortUrlData
|
||||
export const shortUrlDataFromShortUrl = (
|
||||
shortUrl?: ShlinkShortUrl,
|
||||
settings?: ShortUrlCreationSettings,
|
||||
): ShlinkCreateShortUrlData => {
|
||||
const validateUrl = settings?.validateUrls ?? false;
|
||||
|
||||
if (!shortUrl) {
|
||||
return { longUrl: '', validateUrl };
|
||||
}
|
||||
|
||||
return {
|
||||
longUrl: shortUrl.longUrl,
|
||||
tags: shortUrl.tags,
|
||||
title: shortUrl.title ?? undefined,
|
||||
domain: shortUrl.domain ?? undefined,
|
||||
validSince: shortUrl.meta.validSince ?? undefined,
|
||||
validUntil: shortUrl.meta.validUntil ?? undefined,
|
||||
maxVisits: shortUrl.meta.maxVisits ?? undefined,
|
||||
crawlable: shortUrl.crawlable,
|
||||
forwardQuery: shortUrl.forwardQuery,
|
||||
deviceLongUrls: shortUrl.deviceLongUrls && {
|
||||
android: shortUrl.deviceLongUrls.android ?? undefined,
|
||||
ios: shortUrl.deviceLongUrls.ios ?? undefined,
|
||||
desktop: shortUrl.deviceLongUrls.desktop ?? undefined,
|
||||
},
|
||||
validateUrl,
|
||||
};
|
||||
};
|
||||
|
||||
const MULTI_SEGMENT_SEPARATOR = '__';
|
||||
|
||||
export const urlEncodeShortCode = (shortCode: string): string => shortCode.replaceAll('/', MULTI_SEGMENT_SEPARATOR);
|
||||
|
||||
export const urlDecodeShortCode = (shortCode: string): string => shortCode.replaceAll(MULTI_SEGMENT_SEPARATOR, '/');
|
|
@ -1,28 +0,0 @@
|
|||
import { DropdownBtn } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import type { QrErrorCorrection } from '../../../utils/helpers/qrCodes';
|
||||
|
||||
interface QrErrorCorrectionDropdownProps {
|
||||
errorCorrection: QrErrorCorrection;
|
||||
setErrorCorrection: (errorCorrection: QrErrorCorrection) => void;
|
||||
}
|
||||
|
||||
export const QrErrorCorrectionDropdown: FC<QrErrorCorrectionDropdownProps> = (
|
||||
{ errorCorrection, setErrorCorrection },
|
||||
) => (
|
||||
<DropdownBtn text={`Error correction (${errorCorrection})`}>
|
||||
<DropdownItem active={errorCorrection === 'L'} onClick={() => setErrorCorrection('L')}>
|
||||
<b>L</b>ow
|
||||
</DropdownItem>
|
||||
<DropdownItem active={errorCorrection === 'M'} onClick={() => setErrorCorrection('M')}>
|
||||
<b>M</b>edium
|
||||
</DropdownItem>
|
||||
<DropdownItem active={errorCorrection === 'Q'} onClick={() => setErrorCorrection('Q')}>
|
||||
<b>Q</b>uartile
|
||||
</DropdownItem>
|
||||
<DropdownItem active={errorCorrection === 'H'} onClick={() => setErrorCorrection('H')}>
|
||||
<b>H</b>igh
|
||||
</DropdownItem>
|
||||
</DropdownBtn>
|
||||
);
|
|
@ -1,16 +0,0 @@
|
|||
import { DropdownBtn } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import type { QrCodeFormat } from '../../../utils/helpers/qrCodes';
|
||||
|
||||
interface QrFormatDropdownProps {
|
||||
format: QrCodeFormat;
|
||||
setFormat: (format: QrCodeFormat) => void;
|
||||
}
|
||||
|
||||
export const QrFormatDropdown: FC<QrFormatDropdownProps> = ({ format, setFormat }) => (
|
||||
<DropdownBtn text={`Format (${format})`}>
|
||||
<DropdownItem active={format === 'png'} onClick={() => setFormat('png')}>PNG</DropdownItem>
|
||||
<DropdownItem active={format === 'svg'} onClick={() => setFormat('svg')}>SVG</DropdownItem>
|
||||
</DropdownBtn>
|
||||
);
|
|
@ -1,65 +0,0 @@
|
|||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import type { ProblemDetailsError, ShlinkApiClient, ShlinkCreateShortUrlData, ShlinkShortUrl } from '../../api-contract';
|
||||
import { parseApiError } from '../../api-contract/utils';
|
||||
import { createAsyncThunk } from '../../utils/redux';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/shortUrlCreation';
|
||||
|
||||
export type ShortUrlCreation = {
|
||||
saving: false;
|
||||
saved: false;
|
||||
error: false;
|
||||
} | {
|
||||
saving: true;
|
||||
saved: false;
|
||||
error: false;
|
||||
} | {
|
||||
saving: false;
|
||||
saved: false;
|
||||
error: true;
|
||||
errorData?: ProblemDetailsError;
|
||||
} | {
|
||||
result: ShlinkShortUrl;
|
||||
saving: false;
|
||||
saved: true;
|
||||
error: false;
|
||||
};
|
||||
|
||||
const initialState: ShortUrlCreation = {
|
||||
saving: false,
|
||||
saved: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export const createShortUrl = (apiClientFactory: () => ShlinkApiClient) => createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/createShortUrl`,
|
||||
(data: ShlinkCreateShortUrlData): Promise<ShlinkShortUrl> => apiClientFactory().createShortUrl(data),
|
||||
);
|
||||
|
||||
export const shortUrlCreationReducerCreator = (createShortUrlThunk: ReturnType<typeof createShortUrl>) => {
|
||||
const { reducer, actions } = createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState: initialState as ShortUrlCreation, // Without this casting it infers type ShortUrlCreationWaiting
|
||||
reducers: {
|
||||
resetCreateShortUrl: () => initialState,
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(createShortUrlThunk.pending, () => ({ saving: true, saved: false, error: false }));
|
||||
builder.addCase(
|
||||
createShortUrlThunk.rejected,
|
||||
(_, { error }) => ({ saving: false, saved: false, error: true, errorData: parseApiError(error) }),
|
||||
);
|
||||
builder.addCase(
|
||||
createShortUrlThunk.fulfilled,
|
||||
(_, { payload: result }) => ({ result, saving: false, saved: true, error: false }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { resetCreateShortUrl } = actions;
|
||||
|
||||
return {
|
||||
reducer,
|
||||
resetCreateShortUrl,
|
||||
};
|
||||
};
|
|
@ -1,58 +0,0 @@
|
|||
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||
import type { ProblemDetailsError, ShlinkApiClient, ShlinkShortUrl } from '../../api-contract';
|
||||
import { parseApiError } from '../../api-contract/utils';
|
||||
import { createAsyncThunk } from '../../utils/redux';
|
||||
import type { ShortUrlIdentifier } from '../data';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/shortUrlDeletion';
|
||||
|
||||
export interface ShortUrlDeletion {
|
||||
shortCode: string;
|
||||
loading: boolean;
|
||||
deleted: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlDeletion = {
|
||||
shortCode: '',
|
||||
loading: false,
|
||||
deleted: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export const deleteShortUrl = (apiClientFactory: () => ShlinkApiClient) => createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/deleteShortUrl`,
|
||||
async ({ shortCode, domain }: ShortUrlIdentifier): Promise<ShortUrlIdentifier> => {
|
||||
await apiClientFactory().deleteShortUrl(shortCode, domain);
|
||||
return { shortCode, domain };
|
||||
},
|
||||
);
|
||||
|
||||
export const shortUrlDeleted = createAction<ShlinkShortUrl>(`${REDUCER_PREFIX}/shortUrlDeleted`);
|
||||
|
||||
export const shortUrlDeletionReducerCreator = (deleteShortUrlThunk: ReturnType<typeof deleteShortUrl>) => {
|
||||
const { actions, reducer } = createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {
|
||||
resetDeleteShortUrl: () => initialState,
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(
|
||||
deleteShortUrlThunk.pending,
|
||||
(state) => ({ ...state, loading: true, error: false, deleted: false }),
|
||||
);
|
||||
builder.addCase(deleteShortUrlThunk.rejected, (state, { error }) => (
|
||||
{ ...state, errorData: parseApiError(error), loading: false, error: true, deleted: false }
|
||||
));
|
||||
builder.addCase(deleteShortUrlThunk.fulfilled, (state, { payload }) => (
|
||||
{ ...state, shortCode: payload.shortCode, loading: false, error: false, deleted: true }
|
||||
));
|
||||
},
|
||||
});
|
||||
|
||||
const { resetDeleteShortUrl } = actions;
|
||||
|
||||
return { reducer, resetDeleteShortUrl };
|
||||
};
|
|
@ -1,50 +0,0 @@
|
|||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import type { ProblemDetailsError, ShlinkApiClient, ShlinkShortUrl } from '../../api-contract';
|
||||
import { parseApiError } from '../../api-contract/utils';
|
||||
import { createAsyncThunk } from '../../utils/redux';
|
||||
import type { ShortUrlIdentifier } from '../data';
|
||||
import { shortUrlMatches } from '../helpers';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/shortUrlDetail';
|
||||
|
||||
export interface ShortUrlDetail {
|
||||
shortUrl?: ShlinkShortUrl;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export type ShortUrlDetailAction = PayloadAction<ShlinkShortUrl>;
|
||||
|
||||
const initialState: ShortUrlDetail = {
|
||||
loading: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export const shortUrlDetailReducerCreator = (apiClientFactory: () => ShlinkApiClient) => {
|
||||
const getShortUrlDetail = createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/getShortUrlDetail`,
|
||||
async ({ shortCode, domain }: ShortUrlIdentifier, { getState }): Promise<ShlinkShortUrl> => {
|
||||
const { shortUrlsList } = getState();
|
||||
const alreadyLoaded = shortUrlsList?.shortUrls?.data.find((url) => shortUrlMatches(url, shortCode, domain));
|
||||
|
||||
return alreadyLoaded ?? await apiClientFactory().getShortUrl(shortCode, domain);
|
||||
},
|
||||
);
|
||||
|
||||
const { reducer } = createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(getShortUrlDetail.pending, () => ({ loading: true, error: false }));
|
||||
builder.addCase(getShortUrlDetail.rejected, (_, { error }) => (
|
||||
{ loading: false, error: true, errorData: parseApiError(error) }
|
||||
));
|
||||
builder.addCase(getShortUrlDetail.fulfilled, (_, { payload: shortUrl }) => ({ ...initialState, shortUrl }));
|
||||
},
|
||||
});
|
||||
|
||||
return { reducer, getShortUrlDetail };
|
||||
};
|
|
@ -1,49 +0,0 @@
|
|||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import type { ProblemDetailsError, ShlinkApiClient, ShlinkEditShortUrlData, ShlinkShortUrl } from '../../api-contract';
|
||||
import { parseApiError } from '../../api-contract/utils';
|
||||
import { createAsyncThunk } from '../../utils/redux';
|
||||
import type { ShortUrlIdentifier } from '../data';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/shortUrlEdition';
|
||||
|
||||
export interface ShortUrlEdition {
|
||||
shortUrl?: ShlinkShortUrl;
|
||||
saving: boolean;
|
||||
saved: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface EditShortUrl extends ShortUrlIdentifier {
|
||||
data: ShlinkEditShortUrlData;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlEdition = {
|
||||
saving: false,
|
||||
saved: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export const editShortUrl = (apiClientFactory: () => ShlinkApiClient) => createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/editShortUrl`,
|
||||
({ shortCode, domain, data }: EditShortUrl): Promise<ShlinkShortUrl> =>
|
||||
apiClientFactory().updateShortUrl(shortCode, domain, data as any) // TODO parse dates
|
||||
,
|
||||
);
|
||||
|
||||
export const shortUrlEditionReducerCreator = (editShortUrlThunk: ReturnType<typeof editShortUrl>) => createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(editShortUrlThunk.pending, (state) => ({ ...state, saving: true, error: false, saved: false }));
|
||||
builder.addCase(
|
||||
editShortUrlThunk.rejected,
|
||||
(state, { error }) => ({ ...state, saving: false, error: true, saved: false, errorData: parseApiError(error) }),
|
||||
);
|
||||
builder.addCase(
|
||||
editShortUrlThunk.fulfilled,
|
||||
(_, { payload: shortUrl }) => ({ shortUrl, saving: false, error: false, saved: true }),
|
||||
);
|
||||
},
|
||||
});
|
|
@ -1,112 +0,0 @@
|
|||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { assocPath, last, pipe, reject } from 'ramda';
|
||||
import type { ShlinkApiClient, ShlinkShortUrl, ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api-contract';
|
||||
import { createAsyncThunk } from '../../utils/redux';
|
||||
import { createNewVisits } from '../../visits/reducers/visitCreation';
|
||||
import { shortUrlMatches } from '../helpers';
|
||||
import type { createShortUrl } from './shortUrlCreation';
|
||||
import { shortUrlDeleted } from './shortUrlDeletion';
|
||||
import type { editShortUrl } from './shortUrlEdition';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/shortUrlsList';
|
||||
export const ITEMS_IN_OVERVIEW_PAGE = 5;
|
||||
|
||||
export interface ShortUrlsList {
|
||||
shortUrls?: ShlinkShortUrlsResponse;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlsList = {
|
||||
loading: true,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export const listShortUrls = (apiClientFactory: () => ShlinkApiClient) => createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/listShortUrls`,
|
||||
(params: ShlinkShortUrlsListParams | void): Promise<ShlinkShortUrlsResponse> => apiClientFactory().listShortUrls(
|
||||
params ?? {},
|
||||
),
|
||||
);
|
||||
|
||||
export const shortUrlsListReducerCreator = (
|
||||
listShortUrlsThunk: ReturnType<typeof listShortUrls>,
|
||||
editShortUrlThunk: ReturnType<typeof editShortUrl>,
|
||||
createShortUrlThunk: ReturnType<typeof createShortUrl>,
|
||||
) => createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(listShortUrlsThunk.pending, (state) => ({ ...state, loading: true, error: false }));
|
||||
builder.addCase(listShortUrlsThunk.rejected, () => ({ loading: false, error: true }));
|
||||
builder.addCase(
|
||||
listShortUrlsThunk.fulfilled,
|
||||
(_, { payload: shortUrls }) => ({ loading: false, error: false, shortUrls }),
|
||||
);
|
||||
|
||||
builder.addCase(
|
||||
createShortUrlThunk.fulfilled,
|
||||
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.
|
||||
// We can also remove the items above the amount that is displayed there.
|
||||
(state, { payload }) => (!state.shortUrls ? state : assocPath(
|
||||
['shortUrls', 'data'],
|
||||
[payload, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1)],
|
||||
state,
|
||||
)),
|
||||
(state: ShortUrlsList) => (!state.shortUrls ? state : assocPath(
|
||||
['shortUrls', 'pagination', 'totalItems'],
|
||||
state.shortUrls.pagination.totalItems + 1,
|
||||
state,
|
||||
)),
|
||||
),
|
||||
);
|
||||
|
||||
builder.addCase(
|
||||
editShortUrlThunk.fulfilled,
|
||||
(state, { payload: editedShortUrl }) => (!state.shortUrls ? state : assocPath(
|
||||
['shortUrls', 'data'],
|
||||
state.shortUrls.data.map((shortUrl) => {
|
||||
const { shortCode, domain } = editedShortUrl;
|
||||
return shortUrlMatches(shortUrl, shortCode, domain) ? editedShortUrl : shortUrl;
|
||||
}),
|
||||
state,
|
||||
)),
|
||||
);
|
||||
|
||||
builder.addCase(
|
||||
shortUrlDeleted,
|
||||
pipe(
|
||||
(state, { payload }) => (!state.shortUrls ? state : assocPath(
|
||||
['shortUrls', 'data'],
|
||||
reject<ShlinkShortUrl, ShlinkShortUrl[]>((shortUrl) =>
|
||||
shortUrlMatches(shortUrl, payload.shortCode, payload.domain), state.shortUrls.data),
|
||||
state,
|
||||
)),
|
||||
(state) => (!state.shortUrls ? state : assocPath(
|
||||
['shortUrls', 'pagination', 'totalItems'],
|
||||
state.shortUrls.pagination.totalItems - 1,
|
||||
state,
|
||||
)),
|
||||
),
|
||||
);
|
||||
|
||||
builder.addCase(
|
||||
createNewVisits,
|
||||
(state, { payload }) => assocPath(
|
||||
['shortUrls', 'data'],
|
||||
state.shortUrls?.data?.map(
|
||||
// Find the last of the new visit for this short URL, and pick its short URL. It will have an up-to-date amount of visits.
|
||||
(currentShortUrl) => last(
|
||||
payload.createdVisits.filter(
|
||||
({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain),
|
||||
),
|
||||
)?.shortUrl ?? currentShortUrl,
|
||||
),
|
||||
state,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
|
@ -1,94 +0,0 @@
|
|||
import type Bottle from 'bottlejs';
|
||||
import { prop } from 'ramda';
|
||||
import type { ConnectDecorator } from '../../container';
|
||||
import { CreateShortUrl } from '../CreateShortUrl';
|
||||
import { EditShortUrl } from '../EditShortUrl';
|
||||
import { CreateShortUrlResult } from '../helpers/CreateShortUrlResult';
|
||||
import { DeleteShortUrlModal } from '../helpers/DeleteShortUrlModal';
|
||||
import { ExportShortUrlsBtn } from '../helpers/ExportShortUrlsBtn';
|
||||
import { QrCodeModal } from '../helpers/QrCodeModal';
|
||||
import { ShortUrlsRow } from '../helpers/ShortUrlsRow';
|
||||
import { ShortUrlsRowMenu } from '../helpers/ShortUrlsRowMenu';
|
||||
import { createShortUrl, shortUrlCreationReducerCreator } from '../reducers/shortUrlCreation';
|
||||
import { deleteShortUrl, shortUrlDeleted, shortUrlDeletionReducerCreator } from '../reducers/shortUrlDeletion';
|
||||
import { shortUrlDetailReducerCreator } from '../reducers/shortUrlDetail';
|
||||
import { editShortUrl, shortUrlEditionReducerCreator } from '../reducers/shortUrlEdition';
|
||||
import { listShortUrls, shortUrlsListReducerCreator } from '../reducers/shortUrlsList';
|
||||
import { ShortUrlForm } from '../ShortUrlForm';
|
||||
import { ShortUrlsFilteringBar } from '../ShortUrlsFilteringBar';
|
||||
import { ShortUrlsList } from '../ShortUrlsList';
|
||||
import { ShortUrlsTable } from '../ShortUrlsTable';
|
||||
|
||||
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'ShortUrlsFilteringBar');
|
||||
bottle.decorator('ShortUrlsList', connect(
|
||||
['mercureInfo', 'shortUrlsList'],
|
||||
['listShortUrls', 'createNewVisits', 'loadMercureInfo'],
|
||||
));
|
||||
|
||||
bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow');
|
||||
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useTimeoutToggle');
|
||||
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'QrCodeModal');
|
||||
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useTimeoutToggle');
|
||||
bottle.serviceFactory('ShortUrlForm', ShortUrlForm, 'TagsSelector', 'DomainSelector');
|
||||
|
||||
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'ShortUrlForm', 'CreateShortUrlResult');
|
||||
bottle.decorator(
|
||||
'CreateShortUrl',
|
||||
connect(['shortUrlCreation'], ['createShortUrl', 'resetCreateShortUrl']),
|
||||
);
|
||||
|
||||
bottle.serviceFactory('EditShortUrl', EditShortUrl, 'ShortUrlForm');
|
||||
bottle.decorator('EditShortUrl', connect(
|
||||
['shortUrlDetail', 'shortUrlEdition'],
|
||||
['getShortUrlDetail', 'editShortUrl'],
|
||||
));
|
||||
|
||||
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
|
||||
bottle.decorator('DeleteShortUrlModal', connect(
|
||||
['shortUrlDeletion'],
|
||||
['deleteShortUrl', 'shortUrlDeleted', 'resetDeleteShortUrl'],
|
||||
));
|
||||
|
||||
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader');
|
||||
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ExportShortUrlsBtn', 'TagsSelector');
|
||||
|
||||
bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'apiClientFactory', 'ReportExporter');
|
||||
|
||||
// Reducers
|
||||
bottle.serviceFactory(
|
||||
'shortUrlsListReducerCreator',
|
||||
shortUrlsListReducerCreator,
|
||||
'listShortUrls',
|
||||
'editShortUrl',
|
||||
'createShortUrl',
|
||||
);
|
||||
bottle.serviceFactory('shortUrlsListReducer', prop('reducer'), 'shortUrlsListReducerCreator');
|
||||
|
||||
bottle.serviceFactory('shortUrlCreationReducerCreator', shortUrlCreationReducerCreator, 'createShortUrl');
|
||||
bottle.serviceFactory('shortUrlCreationReducer', prop('reducer'), 'shortUrlCreationReducerCreator');
|
||||
|
||||
bottle.serviceFactory('shortUrlEditionReducerCreator', shortUrlEditionReducerCreator, 'editShortUrl');
|
||||
bottle.serviceFactory('shortUrlEditionReducer', prop('reducer'), 'shortUrlEditionReducerCreator');
|
||||
|
||||
bottle.serviceFactory('shortUrlDeletionReducerCreator', shortUrlDeletionReducerCreator, 'deleteShortUrl');
|
||||
bottle.serviceFactory('shortUrlDeletionReducer', prop('reducer'), 'shortUrlDeletionReducerCreator');
|
||||
|
||||
bottle.serviceFactory('shortUrlDetailReducerCreator', shortUrlDetailReducerCreator, 'apiClientFactory');
|
||||
bottle.serviceFactory('shortUrlDetailReducer', prop('reducer'), 'shortUrlDetailReducerCreator');
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('listShortUrls', listShortUrls, 'apiClientFactory');
|
||||
|
||||
bottle.serviceFactory('createShortUrl', createShortUrl, 'apiClientFactory');
|
||||
bottle.serviceFactory('resetCreateShortUrl', prop('resetCreateShortUrl'), 'shortUrlCreationReducerCreator');
|
||||
|
||||
bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'apiClientFactory');
|
||||
bottle.serviceFactory('resetDeleteShortUrl', prop('resetDeleteShortUrl'), 'shortUrlDeletionReducerCreator');
|
||||
bottle.serviceFactory('shortUrlDeleted', () => shortUrlDeleted);
|
||||
|
||||
bottle.serviceFactory('getShortUrlDetail', prop('getShortUrlDetail'), 'shortUrlDetailReducerCreator');
|
||||
|
||||
bottle.serviceFactory('editShortUrl', editShortUrl, 'apiClientFactory');
|
||||
};
|
|
@ -1,96 +0,0 @@
|
|||
import { determineOrderDir, Message, OrderingDropdown, Result, SearchField, sortList } from '@shlinkio/shlink-frontend-kit';
|
||||
import { pipe } from 'ramda';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Row } from 'reactstrap';
|
||||
import { ShlinkApiError } from '../common/ShlinkApiError';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import { useSettings } from '../utils/settings';
|
||||
import type { SimplifiedTag } from './data';
|
||||
import type { TagsOrder, TagsOrderableFields } from './data/TagsListChildrenProps';
|
||||
import { TAGS_ORDERABLE_FIELDS } from './data/TagsListChildrenProps';
|
||||
import type { TagsList as TagsListState } from './reducers/tagsList';
|
||||
import type { TagsTableProps } from './TagsTable';
|
||||
|
||||
export interface TagsListProps {
|
||||
filterTags: (searchTerm: string) => void;
|
||||
forceListTags: Function;
|
||||
tagsList: TagsListState;
|
||||
}
|
||||
|
||||
export const TagsList = (TagsTable: FC<TagsTableProps>) => boundToMercureHub((
|
||||
{ filterTags, forceListTags, tagsList }: TagsListProps,
|
||||
) => {
|
||||
const settings = useSettings();
|
||||
const [order, setOrder] = useState<TagsOrder>(settings.tags?.defaultOrdering ?? {});
|
||||
const resolveSortedTags = pipe(
|
||||
() => tagsList.filteredTags.map((tag): SimplifiedTag => {
|
||||
const theTag = tagsList.stats[tag];
|
||||
const visits = (
|
||||
settings.visits?.excludeBots ? theTag?.visitsSummary?.nonBots : theTag?.visitsSummary?.total
|
||||
) ?? theTag?.visitsCount ?? 0;
|
||||
|
||||
return {
|
||||
tag,
|
||||
visits,
|
||||
shortUrls: theTag?.shortUrlsCount ?? 0,
|
||||
};
|
||||
}),
|
||||
(simplifiedTags) => sortList<SimplifiedTag>(simplifiedTags, order),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
forceListTags();
|
||||
}, []);
|
||||
|
||||
if (tagsList.loading) {
|
||||
return <Message loading />;
|
||||
}
|
||||
|
||||
if (tagsList.error) {
|
||||
return (
|
||||
<Result type="error">
|
||||
<ShlinkApiError errorData={tagsList.errorData} fallbackMessage="Error loading tags :(" />
|
||||
</Result>
|
||||
);
|
||||
}
|
||||
|
||||
const orderByColumn = (field: TagsOrderableFields) => () => {
|
||||
const dir = determineOrderDir(field, order.field, order.dir);
|
||||
|
||||
setOrder({ field: dir ? field : undefined, dir });
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (tagsList.filteredTags.length < 1) {
|
||||
return <Message>No tags found</Message>;
|
||||
}
|
||||
|
||||
const sortedTags = resolveSortedTags();
|
||||
|
||||
return (
|
||||
<TagsTable
|
||||
sortedTags={sortedTags}
|
||||
currentOrder={order}
|
||||
orderByColumn={orderByColumn}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchField className="mb-3" onChange={filterTags} />
|
||||
<Row className="mb-3">
|
||||
<div className="col-lg-6 offset-lg-6">
|
||||
<OrderingDropdown
|
||||
items={TAGS_ORDERABLE_FIELDS}
|
||||
order={order}
|
||||
onChange={(field, dir) => setOrder({ field, dir })}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
{renderContent()}
|
||||
</>
|
||||
);
|
||||
}, () => [Topics.visits]);
|
|
@ -1,10 +0,0 @@
|
|||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
@import '../utils/mixins/sticky-cell';
|
||||
|
||||
.tags-table__header-cell.tags-table__header-cell {
|
||||
@include sticky-cell(false);
|
||||
|
||||
top: $headerHeight;
|
||||
position: sticky;
|
||||
cursor: pointer;
|
||||
}
|
|
@ -1,70 +0,0 @@
|
|||
import { parseQuery, SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
||||
import { splitEvery } from 'ramda';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { SimplePaginator } from '../utils/components/SimplePaginator';
|
||||
import { useQueryState } from '../utils/helpers/hooks';
|
||||
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||
import type { TagsListChildrenProps, TagsOrder, TagsOrderableFields } from './data/TagsListChildrenProps';
|
||||
import type { TagsTableRowProps } from './TagsTableRow';
|
||||
import './TagsTable.scss';
|
||||
|
||||
export interface TagsTableProps extends TagsListChildrenProps {
|
||||
orderByColumn: (field: TagsOrderableFields) => () => void;
|
||||
currentOrder: TagsOrder;
|
||||
}
|
||||
|
||||
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
|
||||
|
||||
export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
|
||||
{ sortedTags, orderByColumn, currentOrder }: TagsTableProps,
|
||||
) => {
|
||||
const isFirstLoad = useRef(true);
|
||||
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;
|
||||
const currentPage = pages[page - 1] ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
!isFirstLoad.current && setPage(1);
|
||||
isFirstLoad.current = false;
|
||||
}, [sortedTags]);
|
||||
useEffect(() => {
|
||||
scrollTo(0, 0);
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<SimpleCard key={page} bodyClassName={showPaginator ? 'pb-1' : ''}>
|
||||
<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-end" onClick={orderByColumn('shortUrls')}>
|
||||
Short URLs <TableOrderIcon currentOrder={currentOrder} field="shortUrls" />
|
||||
</th>
|
||||
<th className="tags-table__header-cell text-lg-end" onClick={orderByColumn('visits')}>
|
||||
Visits <TableOrderIcon currentOrder={currentOrder} field="visits" />
|
||||
</th>
|
||||
<th aria-label="Options" className="tags-table__header-cell" />
|
||||
</tr>
|
||||
<tr><th aria-label="Separator" colSpan={4} className="p-0 border-top-0" /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentPage.length === 0 && <tr><td colSpan={4} className="text-center">No results found</td></tr>}
|
||||
{currentPage.map((tag) => <TagsTableRow key={tag.tag} tag={tag} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{showPaginator && (
|
||||
<div className="sticky-card-paginator">
|
||||
<SimplePaginator pagesCount={pages.length} currentPage={page} setCurrentPage={setPage} />
|
||||
</div>
|
||||
)}
|
||||
</SimpleCard>
|
||||
);
|
||||
};
|
|
@ -1,56 +0,0 @@
|
|||
import { faPencilAlt as editIcon, faTrash as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { RowDropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { prettify } from '../utils/helpers/numbers';
|
||||
import { useRoutesPrefix } from '../utils/routesPrefix';
|
||||
import type { ColorGenerator } from '../utils/services/ColorGenerator';
|
||||
import type { SimplifiedTag, TagModalProps } from './data';
|
||||
import { TagBullet } from './helpers/TagBullet';
|
||||
|
||||
export interface TagsTableRowProps {
|
||||
tag: SimplifiedTag;
|
||||
}
|
||||
|
||||
export const TagsTableRow = (
|
||||
DeleteTagConfirmModal: FC<TagModalProps>,
|
||||
EditTagModal: FC<TagModalProps>,
|
||||
colorGenerator: ColorGenerator,
|
||||
) => ({ tag }: TagsTableRowProps) => {
|
||||
const [isDeleteModalOpen, toggleDelete] = useToggle();
|
||||
const [isEditModalOpen, toggleEdit] = useToggle();
|
||||
const routesPrefix = useRoutesPrefix();
|
||||
|
||||
return (
|
||||
<tr className="responsive-table__row">
|
||||
<th className="responsive-table__cell" data-th="Tag">
|
||||
<TagBullet tag={tag.tag} colorGenerator={colorGenerator} /> {tag.tag}
|
||||
</th>
|
||||
<td className="responsive-table__cell text-lg-end" data-th="Short URLs">
|
||||
<Link to={`${routesPrefix}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}>
|
||||
{prettify(tag.shortUrls)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="responsive-table__cell text-lg-end" data-th="Visits">
|
||||
<Link to={`${routesPrefix}/tag/${tag.tag}/visits`}>
|
||||
{prettify(tag.visits)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="responsive-table__cell text-lg-end">
|
||||
<RowDropdownBtn>
|
||||
<DropdownItem onClick={toggleEdit}>
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth className="me-1" /> Edit
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={toggleDelete}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth className="me-1" /> Delete
|
||||
</DropdownItem>
|
||||
</RowDropdownBtn>
|
||||
</td>
|
||||
|
||||
<EditTagModal tag={tag.tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
||||
<DeleteTagConfirmModal tag={tag.tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
||||
</tr>
|
||||
);
|
||||
};
|
|
@ -1,16 +0,0 @@
|
|||
import type { Order } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { SimplifiedTag } from './index';
|
||||
|
||||
export const TAGS_ORDERABLE_FIELDS = {
|
||||
tag: 'Tag',
|
||||
shortUrls: 'Short URLs',
|
||||
visits: 'Visits',
|
||||
};
|
||||
|
||||
export type TagsOrderableFields = keyof typeof TAGS_ORDERABLE_FIELDS;
|
||||
|
||||
export type TagsOrder = Order<TagsOrderableFields>;
|
||||
|
||||
export interface TagsListChildrenProps {
|
||||
sortedTags: SimplifiedTag[];
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
import type { ShlinkTagsStats } from '@shlinkio/shlink-web-component/api-contract';
|
||||
|
||||
export type TagStats = Omit<ShlinkTagsStats, 'tag'>;
|
||||
|
||||
export interface TagModalProps {
|
||||
tag: string;
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
export interface SimplifiedTag {
|
||||
tag: string;
|
||||
shortUrls: number;
|
||||
visits: number;
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
import { Result } from '@shlinkio/shlink-frontend-kit';
|
||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { ShlinkApiError } from '../../common/ShlinkApiError';
|
||||
import type { TagModalProps } from '../data';
|
||||
import type { TagDeletion } from '../reducers/tagDelete';
|
||||
|
||||
interface DeleteTagConfirmModalProps extends TagModalProps {
|
||||
deleteTag: (tag: string) => Promise<void>;
|
||||
tagDeleted: (tag: string) => void;
|
||||
tagDelete: TagDeletion;
|
||||
}
|
||||
|
||||
export const DeleteTagConfirmModal = (
|
||||
{ tag, toggle, isOpen, deleteTag, tagDelete, tagDeleted }: DeleteTagConfirmModalProps,
|
||||
) => {
|
||||
const { deleting, error, deleted, errorData } = tagDelete;
|
||||
const doDelete = async () => {
|
||||
await deleteTag(tag);
|
||||
toggle();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal toggle={toggle} isOpen={isOpen} centered onClosed={() => deleted && tagDeleted(tag)}>
|
||||
<ModalHeader toggle={toggle} className="text-danger">Delete tag</ModalHeader>
|
||||
<ModalBody>
|
||||
Are you sure you want to delete tag <b>{tag}</b>?
|
||||
{error && (
|
||||
<Result type="error" small className="mt-2">
|
||||
<ShlinkApiError errorData={errorData} fallbackMessage="Something went wrong while deleting the tag :(" />
|
||||
</Result>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="link" onClick={toggle}>Cancel</Button>
|
||||
<Button color="danger" disabled={deleting} onClick={doDelete}>
|
||||
{deleting ? 'Deleting tag...' : 'Delete tag'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
|
@ -1,11 +0,0 @@
|
|||
.edit-tag-modal__color-picker-toggle {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-tag-modal__color-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.edit-tag-modal__popover.edit-tag-modal__popover {
|
||||
border-radius: .6rem;
|
||||
}
|
|
@ -1,81 +0,0 @@
|
|||
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Result, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import { pipe } from 'ramda';
|
||||
import { useState } from 'react';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import { Button, Input, InputGroup, Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
||||
import { ShlinkApiError } from '../../common/ShlinkApiError';
|
||||
import { handleEventPreventingDefault } from '../../utils/helpers';
|
||||
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||
import type { TagModalProps } from '../data';
|
||||
import type { EditTag, TagEdition } from '../reducers/tagEdit';
|
||||
import './EditTagModal.scss';
|
||||
|
||||
interface EditTagModalProps extends TagModalProps {
|
||||
tagEdit: TagEdition;
|
||||
editTag: (editTag: EditTag) => Promise<void>;
|
||||
tagEdited: (tagEdited: EditTag) => void;
|
||||
}
|
||||
|
||||
export const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
||||
{ tag, editTag, toggle, tagEdited, isOpen, tagEdit }: EditTagModalProps,
|
||||
) => {
|
||||
const [newTagName, setNewTagName] = useState(tag);
|
||||
const [color, setColor] = useState(getColorForKey(tag));
|
||||
const [showColorPicker, toggleColorPicker, , hideColorPicker] = useToggle();
|
||||
const { editing, error, edited, errorData } = tagEdit;
|
||||
const saveTag = handleEventPreventingDefault(
|
||||
async () => {
|
||||
await editTag({ oldName: tag, newName: newTagName, color });
|
||||
toggle();
|
||||
},
|
||||
);
|
||||
const onClosed = pipe(hideColorPicker, () => edited && tagEdited({ oldName: tag, newName: newTagName, color }));
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={onClosed}>
|
||||
<form name="editTag" onSubmit={saveTag}>
|
||||
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
|
||||
<ModalBody>
|
||||
<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"
|
||||
hideArrow
|
||||
popperClassName="edit-tag-modal__popover"
|
||||
>
|
||||
<HexColorPicker color={color} onChange={setColor} />
|
||||
</Popover>
|
||||
<Input
|
||||
value={newTagName}
|
||||
placeholder="Tag"
|
||||
required
|
||||
onChange={({ target }) => setNewTagName(target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{error && (
|
||||
<Result type="error" small className="mt-2">
|
||||
<ShlinkApiError errorData={errorData} fallbackMessage="Something went wrong while editing the tag :(" />
|
||||
</Result>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button type="button" color="link" onClick={toggle}>Cancel</Button>
|
||||
<Button color="primary" disabled={editing}>{editing ? 'Saving...' : 'Save'}</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
|
@ -1,24 +0,0 @@
|
|||
.tag {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tag--light-bg {
|
||||
color: #222 !important;
|
||||
}
|
||||
|
||||
.tag:not(:last-child) {
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.tag__close-selected-tag.tag__close-selected-tag {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.tag__close-selected-tag.tag__close-selected-tag:hover {
|
||||
color: inherit !important;
|
||||
opacity: 1 !important;
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import type { FC, MouseEventHandler, PropsWithChildren } from 'react';
|
||||
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||
import './Tag.scss';
|
||||
|
||||
type TagProps = PropsWithChildren<{
|
||||
colorGenerator: ColorGenerator;
|
||||
text: string;
|
||||
className?: string;
|
||||
clearable?: boolean;
|
||||
onClick?: MouseEventHandler;
|
||||
onClose?: MouseEventHandler;
|
||||
}>;
|
||||
|
||||
export const Tag: FC<TagProps> = ({ text, children, clearable, className = '', colorGenerator, onClick, onClose }) => (
|
||||
<span
|
||||
className={classNames('badge tag', className, { 'tag--light-bg': colorGenerator.isColorLightForKey(text) })}
|
||||
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children ?? text}
|
||||
{clearable && (
|
||||
<span aria-label={`Remove ${text}`} className="close tag__close-selected-tag" onClick={onClose}>×</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
|
@ -1,10 +0,0 @@
|
|||
.tag-bullet {
|
||||
$width: 20px;
|
||||
|
||||
border-radius: 50%;
|
||||
width: $width;
|
||||
height: $width;
|
||||
display: inline-block;
|
||||
vertical-align: -4px;
|
||||
margin-right: 7px;
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||
import './TagBullet.scss';
|
||||
|
||||
interface TagBulletProps {
|
||||
tag: string;
|
||||
colorGenerator: ColorGenerator;
|
||||
}
|
||||
|
||||
export const TagBullet = ({ tag, colorGenerator }: TagBulletProps) => (
|
||||
<div
|
||||
style={{ backgroundColor: colorGenerator.getColorForKey(tag) }}
|
||||
className="tag-bullet"
|
||||
/>
|
||||
);
|
|
@ -1,101 +0,0 @@
|
|||
import { useElementRef } from '@shlinkio/shlink-frontend-kit';
|
||||
import classNames from 'classnames';
|
||||
import { useEffect } from 'react';
|
||||
import type { OptionRendererProps, ReactTagsAPI, TagRendererProps, TagSuggestion } from 'react-tag-autocomplete';
|
||||
import { ReactTags } from 'react-tag-autocomplete';
|
||||
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||
import { useSetting } from '../../utils/settings';
|
||||
import type { TagsList } from '../reducers/tagsList';
|
||||
import { normalizeTag } from './index';
|
||||
import { Tag } from './Tag';
|
||||
import { TagBullet } from './TagBullet';
|
||||
|
||||
export type TagsSelectorProps = {
|
||||
selectedTags: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
allowNew?: boolean;
|
||||
};
|
||||
|
||||
type TagsSelectorConnectProps = TagsSelectorProps & {
|
||||
listTags: () => void;
|
||||
tagsList: TagsList;
|
||||
};
|
||||
|
||||
const NOT_FOUND_TAG = 'Tag not found';
|
||||
const NEW_TAG = 'Add tag';
|
||||
const isSelectableOption = (tag: string) => tag !== NOT_FOUND_TAG;
|
||||
const isNewOption = (tag: string) => tag === NEW_TAG;
|
||||
const toTagObject = (tag: string): TagSuggestion => ({ label: tag, value: tag });
|
||||
|
||||
const buildTagRenderer = (colorGenerator: ColorGenerator) => ({ tag, onClick: deleteTag }: TagRendererProps) => (
|
||||
<Tag colorGenerator={colorGenerator} text={tag.label} clearable className="react-tags__tag" onClose={deleteTag} />
|
||||
);
|
||||
const buildOptionRenderer = (colorGenerator: ColorGenerator, api: ReactTagsAPI | null) => (
|
||||
{ option, classNames: classes, ...rest }: OptionRendererProps,
|
||||
) => {
|
||||
const isSelectable = isSelectableOption(option.label);
|
||||
const isNew = isNewOption(option.label);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(classes.option, {
|
||||
[classes.optionIsActive]: isSelectable && option.active,
|
||||
'react-tags__listbox-option--not-selectable': !isSelectable,
|
||||
})}
|
||||
{...rest}
|
||||
>
|
||||
{!isSelectable ? <i>{option.label}</i> : (
|
||||
<>
|
||||
{!isNew && <TagBullet tag={`${option.label}`} colorGenerator={colorGenerator} />}
|
||||
{!isNew ? option.label : <i>Add "{normalizeTag(api?.input.value ?? '')}"</i>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TagsSelector = (colorGenerator: ColorGenerator) => (
|
||||
{ selectedTags, onChange, placeholder, listTags, tagsList, allowNew = true }: TagsSelectorConnectProps,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
listTags();
|
||||
}, []);
|
||||
|
||||
const shortUrlCreation = useSetting('shortUrlCreation');
|
||||
const searchMode = shortUrlCreation?.tagFilteringMode ?? 'startsWith';
|
||||
const apiRef = useElementRef<ReactTagsAPI>();
|
||||
|
||||
return (
|
||||
<ReactTags
|
||||
ref={apiRef}
|
||||
selected={selectedTags.map(toTagObject)}
|
||||
suggestions={tagsList.tags.filter((tag) => !selectedTags.includes(tag)).map(toTagObject)}
|
||||
renderTag={buildTagRenderer(colorGenerator)}
|
||||
renderOption={buildOptionRenderer(colorGenerator, apiRef.current)}
|
||||
activateFirstOption
|
||||
allowNew={allowNew}
|
||||
newOptionText={NEW_TAG}
|
||||
noOptionsText={NOT_FOUND_TAG}
|
||||
placeholderText={placeholder ?? 'Add tags to the URL'}
|
||||
delimiterKeys={['Enter', 'Tab', ',']}
|
||||
suggestionsTransform={
|
||||
(query, suggestions) => {
|
||||
const searchTerm = query.toLowerCase().trim();
|
||||
return searchTerm.length < 1 ? [] : [...suggestions.filter(
|
||||
({ label }) => (searchMode === 'includes' ? label.includes(searchTerm) : label.startsWith(searchTerm)),
|
||||
)].slice(0, 5);
|
||||
}
|
||||
}
|
||||
onDelete={(removedTagIndex) => {
|
||||
const tagsCopy = [...selectedTags];
|
||||
tagsCopy.splice(removedTagIndex, 1);
|
||||
onChange(tagsCopy);
|
||||
}}
|
||||
onAdd={({ label: newTag }) => onChange(
|
||||
// Split any of the new tags by comma, allowing to paste multiple comma-separated tags at once.
|
||||
[...selectedTags, ...newTag.split(',').map(normalizeTag)],
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -1,3 +0,0 @@
|
|||
const ONE_OR_MORE_SPACES_REGEX = /\s+/g;
|
||||
|
||||
export const normalizeTag = (tag: string) => tag.trim().toLowerCase().replace(ONE_OR_MORE_SPACES_REGEX, '-');
|
|
@ -1,140 +0,0 @@
|
|||
@import '../../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
|
||||
// Main wrapper
|
||||
.react-tags {
|
||||
position: relative;
|
||||
padding: 5px 0 0 6px;
|
||||
border-radius: .5rem;
|
||||
background-color: var(--primary-color);
|
||||
border: 1px solid var(--input-border-color);
|
||||
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
||||
|
||||
/* shared font styles */
|
||||
font-size: 1em;
|
||||
line-height: 1.2;
|
||||
|
||||
/* clicking anywhere will focus the input */
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.input-group > .react-tags {
|
||||
flex: 1 1 auto;
|
||||
width: 1%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card .react-tags {
|
||||
background-color: var(--input-color);
|
||||
}
|
||||
|
||||
// Mimic bootstrap input focus ring
|
||||
.react-tags.is-active {
|
||||
box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%);
|
||||
}
|
||||
|
||||
.react-tags__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.react-tags__tag {
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
.react-tags__list {
|
||||
display: inline;
|
||||
vertical-align: 2px;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.react-tags__list-item {
|
||||
display: inline-block;
|
||||
}
|
||||
.react-tags__list-item:not(:last-child) {
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
// The block to search
|
||||
.react-tags__combobox {
|
||||
display: inline-block;
|
||||
|
||||
/* match tag layout */
|
||||
padding: 6px 2px;
|
||||
margin-bottom: 5px;
|
||||
|
||||
/* prevent autoresize overflowing the container */
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $smMin) {
|
||||
.react-tags__combobox {
|
||||
/* this will become the offsetParent for suggestions */
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.react-tags__combobox-input {
|
||||
font-size: 1.25rem;
|
||||
line-height: inherit;
|
||||
color: var(--input-text-color);
|
||||
background-color: inherit;
|
||||
|
||||
/* prevent autoresize overflowing the container */
|
||||
max-width: 100%;
|
||||
|
||||
/* remove styles and layout from this element */
|
||||
margin: 0 0 0 7px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.react-tags__combobox-input::placeholder {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.react-tags__combobox-input::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.react-tags__listbox {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
margin: 4px -1px;
|
||||
padding: 0;
|
||||
background: var(--primary-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: .25rem;
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / .2);
|
||||
}
|
||||
|
||||
@media screen and (min-width: $smMin) {
|
||||
.react-tags__listbox {
|
||||
width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
.react-tags__listbox .react-tags__listbox-option {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.react-tags__listbox .react-tags__listbox-option:not(:last-child) {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.react-tags__listbox .react-tags__listbox-option:hover:not(.react-tags__listbox-option--not-selectable) {
|
||||
cursor: pointer;
|
||||
background-color: var(--active-color);
|
||||
}
|
||||
|
||||
.react-tags__listbox .react-tags__listbox-option.is-active {
|
||||
background-color: var(--active-color);
|
||||
}
|
||||
|
||||
.react-tags__listbox .react-tags__listbox-option.is-disabled {
|
||||
opacity: .5;
|
||||
cursor: auto;
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||
import type { ProblemDetailsError, ShlinkApiClient } from '../../api-contract';
|
||||
import { parseApiError } from '../../api-contract/utils';
|
||||
import { createAsyncThunk } from '../../utils/redux';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/tagDelete';
|
||||
|
||||
export interface TagDeletion {
|
||||
deleting: boolean;
|
||||
deleted: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: TagDeletion = {
|
||||
deleting: false,
|
||||
deleted: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export const tagDeleted = createAction<string>(`${REDUCER_PREFIX}/tagDeleted`);
|
||||
|
||||
export const tagDeleteReducerCreator = (apiClientFactory: () => ShlinkApiClient) => {
|
||||
const deleteTag = createAsyncThunk(`${REDUCER_PREFIX}/deleteTag`, async (tag: string): Promise<void> => {
|
||||
await apiClientFactory().deleteTags([tag]);
|
||||
});
|
||||
|
||||
const { reducer } = createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(deleteTag.pending, () => ({ deleting: true, deleted: false, error: false }));
|
||||
builder.addCase(
|
||||
deleteTag.rejected,
|
||||
(_, { error }) => ({ deleting: false, deleted: false, error: true, errorData: parseApiError(error) }),
|
||||
);
|
||||
builder.addCase(deleteTag.fulfilled, () => ({ deleting: false, deleted: true, error: false }));
|
||||
},
|
||||
});
|
||||
|
||||
return { reducer, deleteTag };
|
||||
};
|
|
@ -1,66 +0,0 @@
|
|||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||
import { pick } from 'ramda';
|
||||
import type { ProblemDetailsError, ShlinkApiClient } from '../../api-contract';
|
||||
import { parseApiError } from '../../api-contract/utils';
|
||||
import { createAsyncThunk } from '../../utils/redux';
|
||||
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/tagEdit';
|
||||
|
||||
export interface TagEdition {
|
||||
oldName?: string;
|
||||
newName?: string;
|
||||
editing: boolean;
|
||||
edited: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface EditTag {
|
||||
oldName: string;
|
||||
newName: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export type EditTagAction = PayloadAction<EditTag>;
|
||||
|
||||
const initialState: TagEdition = {
|
||||
editing: false,
|
||||
edited: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export const tagEdited = createAction<EditTag>(`${REDUCER_PREFIX}/tagEdited`);
|
||||
|
||||
export const editTag = (
|
||||
apiClientFactory: () => ShlinkApiClient,
|
||||
colorGenerator: ColorGenerator,
|
||||
) => createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/editTag`,
|
||||
async ({ oldName, newName, color }: EditTag): Promise<EditTag> => {
|
||||
await apiClientFactory().editTag(oldName, newName);
|
||||
colorGenerator.setColorForKey(newName, color);
|
||||
|
||||
return { oldName, newName, color };
|
||||
},
|
||||
);
|
||||
|
||||
export const tagEditReducerCreator = (editTagThunk: ReturnType<typeof editTag>) => createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(editTagThunk.pending, () => ({ editing: true, edited: false, error: false }));
|
||||
builder.addCase(
|
||||
editTagThunk.rejected,
|
||||
(_, { error }) => ({ editing: false, edited: false, error: true, errorData: parseApiError(error) }),
|
||||
);
|
||||
builder.addCase(editTagThunk.fulfilled, (_, { payload }) => ({
|
||||
...pick(['oldName', 'newName'], payload),
|
||||
editing: false,
|
||||
edited: true,
|
||||
error: false,
|
||||
}));
|
||||
},
|
||||
});
|
|
@ -1,149 +0,0 @@
|
|||
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||
import { isEmpty, reject } from 'ramda';
|
||||
import type { ProblemDetailsError, ShlinkApiClient, ShlinkTags } from '../../api-contract';
|
||||
import { parseApiError } from '../../api-contract/utils';
|
||||
import type { createShortUrl } from '../../short-urls/reducers/shortUrlCreation';
|
||||
import { createAsyncThunk } from '../../utils/redux';
|
||||
import { createNewVisits } from '../../visits/reducers/visitCreation';
|
||||
import type { CreateVisit } from '../../visits/types';
|
||||
import type { TagStats } from '../data';
|
||||
import { tagDeleted } from './tagDelete';
|
||||
import { tagEdited } from './tagEdit';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/tagsList';
|
||||
|
||||
type TagsStatsMap = Record<string, TagStats>;
|
||||
|
||||
export interface TagsList {
|
||||
tags: string[];
|
||||
filteredTags: string[];
|
||||
stats: TagsStatsMap;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
interface ListTags {
|
||||
tags: string[];
|
||||
stats: TagsStatsMap;
|
||||
}
|
||||
|
||||
const initialState: TagsList = {
|
||||
tags: [],
|
||||
filteredTags: [],
|
||||
stats: {},
|
||||
loading: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
type TagIncreaseRecord = Record<string, { bots: number; nonBots: number }>;
|
||||
type TagIncrease = [string, { bots: number; nonBots: number }];
|
||||
|
||||
const renameTag = (oldName: string, newName: string) => (tag: string) => (tag === oldName ? newName : tag);
|
||||
const rejectTag = (tags: string[], tagToReject: string) => reject((tag) => tag === tagToReject, tags);
|
||||
const increaseVisitsForTags = (tags: TagIncrease[], stats: TagsStatsMap) => tags.reduce((theStats, [tag, increase]) => {
|
||||
if (!theStats[tag]) {
|
||||
return theStats;
|
||||
}
|
||||
|
||||
const { bots, nonBots } = increase;
|
||||
const tagStats = theStats[tag];
|
||||
|
||||
return {
|
||||
...theStats,
|
||||
[tag]: {
|
||||
...tagStats,
|
||||
visitsSummary: tagStats.visitsSummary && {
|
||||
total: tagStats.visitsSummary.total + bots + nonBots,
|
||||
bots: tagStats.visitsSummary.bots + bots,
|
||||
nonBots: tagStats.visitsSummary.nonBots + nonBots,
|
||||
},
|
||||
visitsCount: tagStats.visitsCount + bots + nonBots,
|
||||
},
|
||||
};
|
||||
}, { ...stats });
|
||||
const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => Object.entries(
|
||||
createdVisits.reduce<TagIncreaseRecord>((acc, { shortUrl, visit }) => {
|
||||
shortUrl?.tags.forEach((tag) => {
|
||||
if (!acc[tag]) {
|
||||
acc[tag] = { bots: 0, nonBots: 0 };
|
||||
}
|
||||
|
||||
if (visit.potentialBot) {
|
||||
acc[tag].bots += 1;
|
||||
} else {
|
||||
acc[tag].nonBots += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
export const listTags = (apiClientFactory: () => ShlinkApiClient, force = true) => createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/listTags`,
|
||||
async (_: void, { getState }): Promise<ListTags> => {
|
||||
const { tagsList } = getState();
|
||||
|
||||
if (!force && !isEmpty(tagsList.tags)) {
|
||||
return tagsList;
|
||||
}
|
||||
|
||||
const { tags, stats }: ShlinkTags = await apiClientFactory().tagsStats();
|
||||
const processedStats = stats.reduce<TagsStatsMap>((acc, { tag, ...rest }) => {
|
||||
acc[tag] = rest;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return { tags, stats: processedStats };
|
||||
},
|
||||
);
|
||||
|
||||
export const filterTags = createAction<string>(`${REDUCER_PREFIX}/filterTags`);
|
||||
|
||||
export const tagsListReducerCreator = (
|
||||
listTagsThunk: ReturnType<typeof listTags>,
|
||||
createShortUrlThunk: ReturnType<typeof createShortUrl>,
|
||||
) => createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(filterTags, (state, { payload: searchTerm }) => ({
|
||||
...state,
|
||||
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm.toLowerCase())),
|
||||
}));
|
||||
|
||||
builder.addCase(listTagsThunk.pending, (state) => ({ ...state, loading: true, error: false }));
|
||||
builder.addCase(listTagsThunk.rejected, (_, { error }) => (
|
||||
{ ...initialState, error: true, errorData: parseApiError(error) }
|
||||
));
|
||||
builder.addCase(listTagsThunk.fulfilled, (_, { payload }) => (
|
||||
{ ...initialState, stats: payload.stats, tags: payload.tags, filteredTags: payload.tags }
|
||||
));
|
||||
|
||||
builder.addCase(tagDeleted, ({ tags, filteredTags, ...rest }, { payload: tag }) => ({
|
||||
...rest,
|
||||
tags: rejectTag(tags, tag),
|
||||
filteredTags: rejectTag(filteredTags, tag),
|
||||
}));
|
||||
builder.addCase(tagEdited, ({ tags, filteredTags, stats, ...rest }, { payload }) => ({
|
||||
...rest,
|
||||
stats: {
|
||||
...stats,
|
||||
[payload.newName]: stats[payload.oldName],
|
||||
},
|
||||
tags: tags.map(renameTag(payload.oldName, payload.newName)).sort(),
|
||||
filteredTags: filteredTags.map(renameTag(payload.oldName, payload.newName)).sort(),
|
||||
}));
|
||||
builder.addCase(createNewVisits, (state, { payload }) => ({
|
||||
...state,
|
||||
stats: increaseVisitsForTags(calculateVisitsPerTag(payload.createdVisits), state.stats),
|
||||
}));
|
||||
|
||||
builder.addCase(createShortUrlThunk.fulfilled, ({ tags: stateTags, ...rest }, { payload }) => ({
|
||||
...rest,
|
||||
tags: stateTags.concat(payload.tags.filter((tag: string) => !stateTags.includes(tag))), // More performant than [ ...new Set(...) ]
|
||||
}));
|
||||
},
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue