Merge pull request #859 from acelaya-forks/feature/external-web-component

Feature/external web component
This commit is contained in:
Alejandro Celaya 2023-08-14 13:09:28 +02:00 committed by GitHub
commit 30c07c6790
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
314 changed files with 95 additions and 20287 deletions

View file

@ -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*

View file

@ -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
View file

@ -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",

View file

@ -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%",

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>;
};

View file

@ -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;
}

View file

@ -1,3 +0,0 @@
export * from './errors';
export * from './ShlinkApiClient';
export * from './types';

View file

@ -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';
}

View file

@ -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);

View file

@ -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;
}

View file

@ -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>
);
};

View file

@ -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>
)}
</>
);

View file

@ -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);

View file

@ -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);
};

View file

@ -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;
};

View file

@ -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>
);
};

View file

@ -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);
}

View file

@ -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>
);
};

View file

@ -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()}
</>
);
};

View file

@ -1,7 +0,0 @@
import type { ShlinkDomain } from '../../api-contract';
export type DomainStatus = 'validating' | 'valid' | 'invalid';
export interface Domain extends ShlinkDomain {
status: DomainStatus;
}

View file

@ -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>
);
};

View file

@ -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>
</>
);
};

View file

@ -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>
);
};

View file

@ -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 };
},
);

View file

@ -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,
};
};

View file

@ -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');
};

View file

@ -1,2 +0,0 @@
@import './tags/react-tag-autocomplete';
@import './utils/StickyCardPaginator.scss';

View file

@ -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';

View file

@ -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}`;
}

View file

@ -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} />;
};
}

View file

@ -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());
};

View file

@ -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 };
};

View file

@ -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');
};

View file

@ -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 &raquo;</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 &raquo;</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]);

View file

@ -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;
}

View file

@ -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>}
</>
);
};

View file

@ -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>
);

View file

@ -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'],
));
}

View file

@ -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}
/>
</>
);
};

View file

@ -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>}
</>
);
};

View file

@ -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>
);
};

View file

@ -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%;
}

View file

@ -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>
);
};

View file

@ -1,4 +0,0 @@
.short-urls-filtering-bar__tags-icon {
vertical-align: bottom;
font-size: 1.6rem;
}

View file

@ -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>;

View file

@ -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]);

View file

@ -1,7 +0,0 @@
.short-urls-table.short-urls-table {
margin-bottom: -1px;
}
.short-urls-table__header-cell--with-action {
cursor: pointer;
}

View file

@ -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>
&nbsp;&nbsp;/&nbsp;&nbsp;
<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>;

View file

@ -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;
}

View file

@ -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&nbsp;
<b><i>&quot;Use existing URL if found&quot;</i></b>
&nbsp;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} />
</>
);
};

View file

@ -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;
}

View file

@ -1,4 +0,0 @@
.create-short-url-result__copy-btn {
margin-left: 10px;
vertical-align: inherit;
}

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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} />;
};

View file

@ -1,4 +0,0 @@
.qr-code-modal__img {
max-width: 100%;
box-shadow: 0 0 .25rem rgb(0 0 0 / .2);
}

View file

@ -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>
);
};

View file

@ -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>;
};

View file

@ -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>
);

View file

@ -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>
</>
);
};

View file

@ -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;
}

View file

@ -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>
</>
);
};

View file

@ -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>
);
};

View file

@ -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)));
}
}

View file

@ -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>
);
};

View file

@ -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>;

View file

@ -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)}
/>
))}
</>
);
};

View file

@ -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];
};

View file

@ -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, '/');

View file

@ -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>
);

View file

@ -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>
);

View file

@ -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,
};
};

View file

@ -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 };
};

View file

@ -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 };
};

View file

@ -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 }),
);
},
});

View file

@ -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,
),
);
},
});

View file

@ -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');
};

View file

@ -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]);

View file

@ -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;
}

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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[];
}

View file

@ -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;
}

View file

@ -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>
);
};

View file

@ -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;
}

View file

@ -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>
);
};

View file

@ -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;
}

View file

@ -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}>&times;</span>
)}
</span>
);

View file

@ -1,10 +0,0 @@
.tag-bullet {
$width: 20px;
border-radius: 50%;
width: $width;
height: $width;
display: inline-block;
vertical-align: -4px;
margin-right: 7px;
}

View file

@ -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"
/>
);

View file

@ -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 &quot;{normalizeTag(api?.input.value ?? '')}&quot;</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)],
)}
/>
);
};

View file

@ -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, '-');

View file

@ -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;
}

View file

@ -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 };
};

View file

@ -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,
}));
},
});

View file

@ -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