Merge pull request #608 from shlinkio/develop

Release 3.6.0
This commit is contained in:
Alejandro Celaya 2022-03-17 20:41:27 +01:00 committed by GitHub
commit 3e58d861ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
190 changed files with 7869 additions and 12880 deletions

View file

@ -1,4 +1,5 @@
./.github
./.stryker-tmp
./build
./coverage
./node_modules

View file

@ -17,6 +17,8 @@
"ignorePatterns": ["src/service*.ts"],
"rules": {
"complexity": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "off"
"@typescript-eslint/no-unnecessary-type-assertion": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unsafe-call": "off"
}
}

View file

@ -16,7 +16,7 @@ jobs:
with:
node-version: 16.13
- name: Generate release assets
run: npm ci && npm run build ${GITHUB_REF#refs/tags/v}
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
- name: Publish release with assets
uses: docker://antonyurchenko/git-release:latest
env:

View file

@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## [3.6.0] - 2022-03-17
### Added
* [#558](https://github.com/shlinkio/shlink-web-client/pull/558) Added dark text for tags where the generated background is too light, improving its legibility.
* [#570](https://github.com/shlinkio/shlink-web-client/pull/570) Added new section to load non-orphan visits all together when consuming Shlink 3.0.0.
* [#556](https://github.com/shlinkio/shlink-web-client/pull/556) Added support to filter short URLs list by "all" tags when consuming Shlink 3.0.0.
* [#549](https://github.com/shlinkio/shlink-web-client/pull/549) Allowed to export the list of short URLs as CSV.
### Changed
* [#543](https://github.com/shlinkio/shlink-web-client/pull/543) Redesigned settings section.
* [#567](https://github.com/shlinkio/shlink-web-client/pull/567) Improved Shlink 3.0.0 compatibility by checking the `INVALID_SHORT_URL_DELETION` error code when deleting short URLs.
* [#448](https://github.com/shlinkio/shlink-web-client/pull/448) Updated to bootstrap v5.
* [#524](https://github.com/shlinkio/shlink-web-client/pull/524) Updated to react-router v6.
* [#576](https://github.com/shlinkio/shlink-web-client/pull/576) Updated to fontawesome v6.
* [#579](https://github.com/shlinkio/shlink-web-client/pull/579) Replaced react-color with react-colorful.
* [#564](https://github.com/shlinkio/shlink-web-client/pull/564) Updated most of the dependencies.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#589](https://github.com/shlinkio/shlink-web-client/pull/589) Fixed alignment of shlink versions footer, by basing the logic on the presence of the sidebar instead of selected server.
## [3.5.1] - 2022-01-08
### Added
* *Nothing*

View file

@ -2,8 +2,7 @@ FROM node:16.13-alpine as node
COPY . /shlink-web-client
ARG VERSION="latest"
ENV VERSION ${VERSION}
RUN cd /shlink-web-client && \
npm install && npm run build -- ${VERSION} --no-dist
RUN cd /shlink-web-client && npm ci && npm run build
FROM nginx:1.21-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"

15
babel.config.js Normal file
View file

@ -0,0 +1,15 @@
module.exports = {
presets: [
[
'react-app',
{
runtime: 'automatic',
typescript: true,
},
],
],
plugins: [
'@babel/plugin-proposal-optional-chaining',
'@babel/plugin-proposal-nullish-coalescing-operator',
],
};

View file

@ -1,13 +0,0 @@
// This is a custom Jest transformer turning style imports into empty objects.
// http://facebook.github.io/jest/docs/en/webpack.html
module.exports = {
process() {
return 'module.exports = {};';
},
getCacheKey() {
// The output is always the same.
return 'cssTransform';
},
};

View file

@ -15,37 +15,23 @@ module.exports = {
lines: 85,
},
},
resolver: 'jest-pnp-resolver',
setupFiles: [
'react-app-polyfill/jsdom',
'<rootDir>/config/setupEnzyme.js',
],
testMatch: [ '<rootDir>/test/**/*.test.{js,jsx,ts,tsx}' ],
setupFiles: [ '<rootDir>/config/setupEnzyme.js' ],
testMatch: [ '<rootDir>/test/**/*.test.{ts,tsx}' ],
testEnvironment: 'jsdom',
testURL: 'http://localhost',
transform: {
'^.+\\.(ts|tsx|js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
'^(?!.*\\.(ts|tsx|js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
'^.+\\.(ts|tsx|js)$': '<rootDir>/node_modules/babel-jest',
'^(?!.*\\.(ts|tsx|js|json)$)': '<rootDir>/config/jest/fileTransform.js',
},
transformIgnorePatterns: [
'<rootDir>/.stryker-tmp',
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
'^.+\\.module\\.(css|sass|scss)$',
'^.+\\.module\\.scss$',
],
moduleNameMapper: {
'^react-native$': 'react-native-web',
'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy',
'^.+\\.module\\.scss$': 'identity-obj-proxy',
// Reactstrap module resolution does not work in jest for some reason. Manually mapping it solves the problem
'reactstrap': '<rootDir>/node_modules/reactstrap/dist/reactstrap.umd.js',
},
moduleFileExtensions: [
'web.js',
'js',
'web.ts',
'ts',
'web.tsx',
'tsx',
'json',
'web.jsx',
'jsx',
'node',
],
moduleFileExtensions: [ 'js', 'ts', 'tsx', 'json' ],
};

16626
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,8 @@
"lint:js:fix": "npm run lint:js -- --fix",
"start": "node scripts/start.js",
"serve:build": "serve ./build",
"build": "node scripts/build.js",
"build": "node scripts/build.js && node scripts/replace-version.js",
"build:dist": "npm run build && node scripts/create-dist-file.js",
"test": "node scripts/test.js --env=jsdom --colors --verbose",
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
"test:ci": "npm run test:coverage -- --coverageReporters=clover",
@ -22,84 +23,81 @@
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.2",
"@fortawesome/fontawesome-svg-core": "^1.2.34",
"@fortawesome/free-regular-svg-icons": "^5.15.2",
"@fortawesome/free-solid-svg-icons": "^5.15.2",
"@fortawesome/react-fontawesome": "^0.1.14",
"axios": "^0.21.2",
"bootstrap": "^4.6.0",
"@fortawesome/fontawesome-free": "^6.0.0",
"@fortawesome/fontawesome-svg-core": "^1.3.0",
"@fortawesome/free-regular-svg-icons": "^6.0.0",
"@fortawesome/free-solid-svg-icons": "^6.0.0",
"@fortawesome/react-fontawesome": "^0.1.17",
"axios": "^0.26.0",
"bootstrap": "^5.1.3",
"bottlejs": "^2.0.0",
"bowser": "^2.11.0",
"chart.js": "^3.5.1",
"classnames": "^2.2.6",
"compare-versions": "^3.6.0",
"chart.js": "^3.7.1",
"classnames": "^2.3.1",
"compare-versions": "^4.1.3",
"csvjson": "^5.1.0",
"date-fns": "^2.22.1",
"event-source-polyfill": "^1.0.22",
"date-fns": "^2.28.0",
"event-source-polyfill": "^1.0.25",
"leaflet": "^1.7.1",
"promise": "^8.1.0",
"qs": "^6.9.6",
"ramda": "^0.27.1",
"react": "^17.0.1",
"react-chartjs-2": "^3.0.4",
"react-color": "^2.19.3",
"react-copy-to-clipboard": "^5.0.2",
"react-datepicker": "^3.6.0",
"react-dom": "^17.0.1",
"react-external-link": "^1.2.0",
"react-leaflet": "^3.1.0",
"react-redux": "^7.2.2",
"react-router-dom": "^5.2.0",
"react-swipeable": "^6.0.1",
"react-tag-autocomplete": "^6.1.0",
"reactstrap": "^8.9.0",
"redux": "^4.0.5",
"redux-localstorage-simple": "^2.4.0",
"redux-thunk": "^2.3.0",
"ramda": "^0.27.2",
"react": "^17.0.2",
"react-chartjs-2": "^3.3.0",
"react-colorful": "^5.5.1",
"react-copy-to-clipboard": "^5.0.4",
"react-datepicker": "^4.7.0",
"react-dom": "^17.0.2",
"react-external-link": "^1.2.2",
"react-leaflet": "^3.2.5",
"react-redux": "^7.2.6",
"react-router-dom": "^6.2.2",
"react-swipeable": "^6.2.0",
"react-tag-autocomplete": "^6.3.0",
"reactstrap": "^9.0.1",
"redux": "^4.1.2",
"redux-localstorage-simple": "^2.4.1",
"redux-thunk": "^2.4.1",
"uuid": "^8.3.2",
"workbox-core": "^6.1.5",
"workbox-expiration": "^6.1.5",
"workbox-precaching": "^6.1.5",
"workbox-routing": "^6.1.5",
"workbox-strategies": "^6.1.5"
"workbox-core": "^6.5.1",
"workbox-expiration": "^6.5.1",
"workbox-precaching": "^6.5.1",
"workbox-routing": "^6.5.1",
"workbox-strategies": "^6.5.1"
},
"devDependencies": {
"@babel/core": "^7.13.8",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
"@babel/core": "^7.17.5",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7",
"@babel/plugin-proposal-optional-chaining": "^7.16.7",
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
"@stryker-mutator/core": "^5.4.1",
"@stryker-mutator/jest-runner": "^5.4.1",
"@stryker-mutator/typescript-checker": "^5.4.1",
"@stryker-mutator/core": "^5.6.1",
"@stryker-mutator/jest-runner": "^5.6.1",
"@stryker-mutator/typescript-checker": "^5.6.1",
"@svgr/webpack": "^5.5.0",
"@types/classnames": "^2.2.11",
"@types/enzyme": "^3.10.10",
"@types/jest": "^27.0.2",
"@types/leaflet": "^1.5.23",
"@types/qs": "^6.9.5",
"@types/ramda": "^0.27.38",
"@types/react": "^17.0.2",
"@types/react-color": "^3.0.4",
"@types/react-copy-to-clipboard": "^5.0.0",
"@types/react-datepicker": "^3.1.5",
"@types/react-dom": "^17.0.1",
"@types/react-leaflet": "^2.5.2",
"@types/react-redux": "^7.1.16",
"@types/react-router-dom": "^5.1.7",
"@types/react-tag-autocomplete": "^6.1.0",
"@types/uuid": "^8.3.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
"adm-zip": "^0.4.16",
"autoprefixer": "^10.0.2",
"babel-core": "7.0.0-bridge.0",
"babel-jest": "^27.3.1",
"babel-loader": "^8.2.1",
"babel-plugin-named-asset-import": "^0.3.7",
"babel-preset-react-app": "^10.0.0",
"@types/classnames": "^2.3.1",
"@types/enzyme": "^3.10.11",
"@types/jest": "^27.4.1",
"@types/leaflet": "^1.7.9",
"@types/qs": "^6.9.7",
"@types/ramda": "0.27.38",
"@types/react": "^17.0.39",
"@types/react-color": "^3.0.6",
"@types/react-copy-to-clipboard": "^5.0.2",
"@types/react-datepicker": "^4.3.4",
"@types/react-dom": "^17.0.13",
"@types/react-leaflet": "^2.8.2",
"@types/react-redux": "^7.1.23",
"@types/react-tag-autocomplete": "^6.1.1",
"@types/uuid": "^8.3.4",
"@wojtekmaj/enzyme-adapter-react-17": "0.6.5",
"adm-zip": "^0.5.9",
"autoprefixer": "^10.4.2",
"babel-jest": "^27.5.1",
"babel-loader": "^8.2.3",
"babel-plugin-named-asset-import": "^0.3.8",
"babel-preset-react-app": "10.0.0",
"babel-runtime": "^6.26.0",
"bfj": "^7.0.2",
"case-sensitive-paths-webpack-plugin": "^2.3.0",
"case-sensitive-paths-webpack-plugin": "^2.4.0",
"chalk": "^4.1.2",
"css-loader": "^5.0.1",
"dart-sass": "^1.25.0",
@ -113,26 +111,22 @@
"fs-extra": "^9.0.1",
"html-webpack-plugin": "^4.5.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.3.1",
"jest-pnp-resolver": "^1.2.2",
"jest-resolve": "^27.3.1",
"jest": "^27.5.1",
"mini-css-extract-plugin": "^1.3.1",
"object-assign": "^4.1.1",
"optimize-css-assets-webpack-plugin": "^5.0.4",
"pnp-webpack-plugin": "^1.6.4",
"postcss": "^8.1.7",
"pnp-webpack-plugin": "^1.7.0",
"postcss": "^8.4.8",
"postcss-flexbugs-fixes": "^4.2.1",
"postcss-loader": "^3.0.0",
"postcss-preset-env": "^6.7.0",
"postcss-safe-parser": "^5.0.2",
"raf": "^3.4.1",
"react-app-polyfill": "^2.0.0",
"react-dev-utils": "^11.0.0",
"resolve": "^1.19.0",
"sass": "^1.29.0",
"resolve": "^1.22.0",
"sass": "^1.49.9",
"sass-loader": "^10.1.0",
"serve": "^12.0.0",
"stryker-cli": "^1.0.0",
"stryker-cli": "^1.0.2",
"style-loader": "^2.0.0",
"stylelint": "^13.7.2",
"stylelint-config-adidas": "^1.3.0",
@ -141,29 +135,14 @@
"stylelint-scss": "^3.18.0",
"sw-precache-webpack-plugin": "^1.0.0",
"terser-webpack-plugin": "^4.2.3",
"ts-jest": "^27.0.7",
"ts-mockery": "^1.2.0",
"typescript": "^4.4.4",
"typescript": "^4.6.2",
"url-loader": "^4.1.1",
"webpack": "^4.44.2",
"webpack-dev-server": "^3.11.0",
"webpack": "^4.46.0",
"webpack-dev-server": "^3.11.3",
"webpack-manifest-plugin": "^2.2.0",
"whatwg-fetch": "^3.5.0",
"workbox-webpack-plugin": "^6.1.5"
},
"babel": {
"presets": [
[
"react-app",
{
"runtime": "automatic"
}
]
],
"plugins": [
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator"
]
"whatwg-fetch": "^3.6.2",
"workbox-webpack-plugin": "^6.5.1"
},
"browserslist": [
">0.2%",

View file

@ -18,7 +18,6 @@ const chalk = require('chalk');
const fs = require('fs-extra');
const webpack = require('webpack');
const bfj = require('bfj');
const AdmZip = require('adm-zip');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
@ -44,8 +43,6 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
const argvSliceStart = 2;
const argv = process.argv.slice(argvSliceStart);
const writeStatsJson = argv.includes('--stats');
const withoutDist = argv.includes('--no-dist');
const { version, hasVersion } = getVersionFromArgs(argv);
// Generate configuration
const config = configFactory('production');
@ -84,7 +81,6 @@ checkBrowsers(paths.appPath, isInteractive)
);
} else {
console.log(chalk.green('Compiled successfully.\n'));
hasVersion && replaceVersionPlaceholder(version);
}
console.log('File sizes after gzip:\n');
@ -103,7 +99,6 @@ checkBrowsers(paths.appPath, isInteractive)
process.exit(1);
},
)
.then(() => hasVersion && !withoutDist && zipDist(version))
.catch((err) => {
if (err && err.message) {
console.log(err.message);
@ -185,43 +180,3 @@ function copyPublicFolder() {
filter: (file) => file !== paths.appHtml,
});
}
function zipDist(version) {
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
const zip = new AdmZip();
try {
if (fs.existsSync(versionFileName)) {
fs.unlink(versionFileName);
}
zip.addLocalFolder('./build', `shlink-web-client_${version}_dist`);
zip.writeZip(versionFileName);
console.log(chalk.green('Dist file properly generated'));
} catch (e) {
console.log(chalk.red('An error occurred while generating dist file'));
console.log(e);
}
console.log();
}
function getVersionFromArgs(argv) {
const [ version ] = argv;
return { version, hasVersion: !!version };
}
function replaceVersionPlaceholder(version) {
const staticJsFilesPath = './build/static/js';
const versionPlaceholder = '%_VERSION_%';
const isMainFile = (file) => file.startsWith('main.') && file.endsWith('.js');
const [ mainJsFile ] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
const filePath = `${staticJsFilesPath}/${mainJsFile}`;
const fileContent = fs.readFileSync(filePath, 'utf-8');
const replaced = fileContent.replace(versionPlaceholder, version);
fs.writeFileSync(filePath, replaced, 'utf-8');
}

View file

@ -0,0 +1,36 @@
/* eslint-disable no-console, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'production';
process.env.NODE_ENV = 'production';
const chalk = require('chalk');
const AdmZip = require('adm-zip');
const fs = require('fs-extra');
function zipDist(version) {
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
const zip = new AdmZip();
try {
if (fs.existsSync(versionFileName)) {
fs.unlink(versionFileName);
}
zip.addLocalFolder('./build', `shlink-web-client_${version}_dist`);
zip.writeZip(versionFileName);
console.log(chalk.green('Dist file properly generated'));
} catch (e) {
console.log(chalk.red('An error occurred while generating dist file'));
console.log(e);
}
console.log();
}
const version = process.env.VERSION;
if (version) {
zipDist(version);
}

View file

@ -0,0 +1,20 @@
const fs = require('fs-extra');
function replaceVersionPlaceholder(version) {
const staticJsFilesPath = './build/static/js';
const versionPlaceholder = '%_VERSION_%';
const isMainFile = (file) => file.startsWith('main.') && file.endsWith('.js');
const [ mainJsFile ] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
const filePath = `${staticJsFilesPath}/${mainJsFile}`;
const fileContent = fs.readFileSync(filePath, 'utf-8');
const replaced = fileContent.replace(versionPlaceholder, version);
fs.writeFileSync(filePath, replaced, 'utf-8');
}
const version = process.env.VERSION;
if (version) {
replaceVersionPlaceholder(version);
}

View file

@ -10,7 +10,7 @@ declare module 'event-source-polyfill' {
declare module 'csvjson' {
export declare class CsvJson {
public toObject<T>(content: string): T[];
public toCSV<T>(data: T[], options: { headers: 'full' | 'none' | 'relative' | 'key' }): string;
public toCSV<T>(data: T[], options: { headers: 'full' | 'none' | 'relative' | 'key'; wrap?: true }): string;
}
}

View file

@ -60,6 +60,10 @@ export default class ShlinkApiClient {
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query)
.then(({ data }) => data.visits);
public readonly getNonOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
this.performRequest<{ visits: ShlinkVisits }>('/visits/non-orphan', 'GET', query)
.then(({ data }) => data.visits);
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
.then(({ data }) => data.visits);

View file

@ -86,6 +86,8 @@ export interface ShlinkDomainsResponse {
defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10
}
export type TagsFilteringMode = 'all' | 'any';
export interface ShlinkShortUrlsListParams {
page?: string;
itemsPerPage?: number;
@ -94,6 +96,7 @@ export interface ShlinkShortUrlsListParams {
startDate?: string;
endDate?: string;
orderBy?: ShortUrlsOrder;
tagsMode?: TagsFilteringMode;
}
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> {
@ -114,6 +117,6 @@ export interface InvalidArgumentError extends ProblemDetailsError {
}
export interface InvalidShortUrlDeletion extends ProblemDetailsError {
type: 'INVALID_SHORTCODE_DELETION';
type: 'INVALID_SHORTCODE_DELETION' | 'INVALID_SHORT_URL_DELETION';
threshold: number;
}

View file

@ -7,4 +7,4 @@ export const isInvalidArgumentError = (error?: ProblemDetailsError): error is In
error?.type === 'INVALID_ARGUMENT';
export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion =>
error?.type === 'INVALID_SHORTCODE_DELETION';
error?.type === 'INVALID_SHORTCODE_DELETION' || error?.type === 'INVALID_SHORT_URL_DELETION';

View file

@ -1,5 +1,5 @@
import { useEffect, FC } from 'react';
import { Route, RouteChildrenProps, Switch } from 'react-router-dom';
import { Route, Routes, useLocation } from 'react-router-dom';
import classNames from 'classnames';
import NotFound from '../common/NotFound';
import { ServersMap } from '../servers/data';
@ -9,7 +9,7 @@ import { AppUpdateBanner } from '../common/AppUpdateBanner';
import { forceUpdate } from '../utils/helpers/sw';
import './App.scss';
interface AppProps extends RouteChildrenProps {
interface AppProps {
fetchServers: () => void;
servers: ServersMap;
settings: Settings;
@ -26,7 +26,8 @@ const App = (
Settings: FC,
ManageServers: FC,
ShlinkVersionsContainer: FC,
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate, location }: AppProps) => {
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
const location = useLocation();
const isHome = location.pathname === '/';
useEffect(() => {
@ -44,15 +45,15 @@ const App = (
<div className="app">
<div className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/settings" component={Settings} />
<Route exact path="/manage-servers" component={ManageServers} />
<Route exact path="/server/create" component={CreateServer} />
<Route exact path="/server/:serverId/edit" component={EditServer} />
<Route path="/server/:serverId" component={MenuLayout} />
<Route component={NotFound} />
</Switch>
<Routes>
<Route index element={<Home />} />
<Route path="/settings/*" element={<Settings />} />
<Route path="/manage-servers" element={<ManageServers />} />
<Route path="/server/create" element={<CreateServer />} />
<Route path="/server/:serverId/edit" element={<EditServer />} />
<Route path="/server/:serverId/*" element={<MenuLayout />} />
<Route path="*" element={<NotFound />} />
</Routes>
</div>
<div className="shlink-footer">

View file

@ -1,9 +1,9 @@
import Bottle, { Decorator } from 'bottlejs';
import Bottle from 'bottlejs';
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
import App from '../App';
import { ConnectDecorator } from '../../container/types';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory(
'App',
@ -18,7 +18,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'ShlinkVersionsContainer',
);
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ]));
bottle.decorator('App', withRouter);
// Actions
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);

View file

@ -24,8 +24,8 @@ export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forc
<h4 className="mb-4">This app has just been updated!</h4>
<p className="mb-0">
Restart it to enjoy the new features.
<Button disabled={isUpdating} className="ml-2" color="secondary" size="sm" onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ml-1" /></>}
<Button disabled={isUpdating} className="ms-2" color="secondary" size="sm" onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ms-1" /></>}
{isUpdating && <>Restarting...</>}
</Button>
</p>

View file

@ -8,9 +8,8 @@ import {
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { FC } from 'react';
import { NavLink, NavLinkProps } from 'react-router-dom';
import { NavLink, NavLinkProps, useLocation } from 'react-router-dom';
import classNames from 'classnames';
import { Location } from 'history';
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
import { isServerWithId, SelectedServer } from '../servers/data';
import { supportsDomainRedirects } from '../utils/helpers/features';
@ -28,8 +27,7 @@ interface AsideMenuItemProps extends NavLinkProps {
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
<NavLink
className={classNames('aside-menu__item', className)}
activeClassName="aside-menu__item--selected"
className={({ isActive }) => classNames('aside-menu__item', className, { 'aside-menu__item--selected': isActive })}
to={to}
{...rest}
>
@ -42,11 +40,11 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
) => {
const hasId = isServerWithId(selectedServer);
const serverId = hasId ? selectedServer.id : '';
const { pathname } = useLocation();
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
const asideClass = classNames('aside-menu', {
'aside-menu--hidden': !showOnMobile,
});
const shortUrlsIsActive = (_: null, location: Location) => location.pathname.match('/list-short-urls') !== null;
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
return (
@ -56,7 +54,10 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
<span className="aside-menu__item-text">Overview</span>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
<AsideMenuItem
to={buildPath('/list-short-urls/1')}
className={classNames({ 'aside-menu__item--selected': pathname.match('/list-short-urls') !== null })}
>
<FontAwesomeIcon fixedWidth icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span>
</AsideMenuItem>

View file

@ -1,6 +1,9 @@
@import '../utils/base';
@import '../utils/mixins/vertical-align';
$mainCardWidth: 720px;
$fiveColumnsSize: .4167; // 12 / 5 -> Can't use "/" operator in latest dart-sass
.home {
position: relative;
padding-top: 15px;
@ -21,12 +24,12 @@
.home__logo {
@include vertical-align();
width: calc(100% - 3rem);
width: calc(#{$mainCardWidth * $fiveColumnsSize} - 3rem);
}
.home__main-card {
margin: 0 auto;
max-width: 720px;
max-width: $mainCardWidth;
@media (min-width: $mdMin) {
@include vertical-align();

View file

@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { isEmpty, values } from 'ramda';
import { Link, RouteChildrenProps } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { Card, Row } from 'reactstrap';
import { ExternalLink } from 'react-external-link';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@ -10,11 +10,12 @@ import { ServersMap } from '../servers/data';
import { ShlinkLogo } from './img/ShlinkLogo';
import './Home.scss';
export interface HomeProps extends RouteChildrenProps {
export interface HomeProps {
servers: ServersMap;
}
const Home = ({ servers, history }: HomeProps) => {
const Home = ({ servers }: HomeProps) => {
const navigate = useNavigate();
const serversList = values(servers);
const hasServers = !isEmpty(serversList);
@ -22,13 +23,13 @@ const Home = ({ servers, history }: HomeProps) => {
// Try to redirect to the first server marked as auto-connect
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
autoConnectServer && history.push(`/server/${autoConnectServer.id}`);
autoConnectServer && navigate(`/server/${autoConnectServer.id}`);
}, []);
return (
<div className="home">
<Card className="home__main-card">
<Row noGutters>
<Row className="g-0">
<div className="col-md-5 d-none d-md-block">
<div className="home__logo-wrapper">
<div className="home__logo">
@ -45,14 +46,14 @@ const Home = ({ servers, history }: HomeProps) => {
<div className="p-4 text-center">
<p className="mb-5">This application will help you manage your Shlink servers.</p>
<p>
<Link to="/server/create" className="btn btn-outline-primary btn-lg mr-2">
<FontAwesomeIcon icon={faPlus} /> <span className="ml-1">Add a server</span>
<Link to="/server/create" className="btn btn-outline-primary btn-lg me-2">
<FontAwesomeIcon icon={faPlus} /> <span className="ms-1">Add a server</span>
</Link>
</p>
<p className="mb-0 mt-5">
<ExternalLink href="https://shlink.io/documentation">
<small>
<span className="mr-1">Learn more about Shlink</span> <FontAwesomeIcon icon={faExternalLinkAlt} />
<span className="me-1">Learn more about Shlink</span> <FontAwesomeIcon icon={faExternalLinkAlt} />
</small>
</ExternalLink>
</p>

View file

@ -1,16 +1,16 @@
import { faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { FC, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
import classNames from 'classnames';
import { RouteComponentProps } from 'react-router';
import { useToggle } from '../utils/helpers/hooks';
import { ShlinkLogo } from './img/ShlinkLogo';
import './MainHeader.scss';
const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps) => {
const MainHeader = (ServersDropdown: FC) => () => {
const [ isOpen, toggleOpen, , close ] = useToggle();
const location = useLocation();
const { pathname } = location;
useEffect(close, [ location ]);
@ -29,9 +29,9 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
</NavbarToggler>
<Collapse navbar isOpen={isOpen}>
<Nav navbar className="ml-auto">
<Nav navbar className="ms-auto">
<NavItem>
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
<FontAwesomeIcon icon={cogsIcon} />&nbsp; Settings
</NavLink>
</NavItem>

View file

@ -1,13 +1,7 @@
@import '../utils/base';
.menu-layout__swipeable {
$offset: 15px;
height: 100%;
margin-right: -$offset;
margin-left: -$offset;
padding-left: $offset;
padding-right: $offset;
}
.menu-layout__swipeable-inner {

View file

@ -1,16 +1,21 @@
import { FC, useEffect } from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
import { supportsDomainRedirects, supportsOrphanVisits } from '../utils/helpers/features';
import { supportsDomainRedirects, supportsNonOrphanVisits, supportsOrphanVisits } from '../utils/helpers/features';
import { isReachableServer } from '../servers/data';
import NotFound from './NotFound';
import { AsideMenuProps } from './AsideMenu';
import './MenuLayout.scss';
interface MenuLayoutProps {
sidebarPresent: Function;
sidebarNotPresent: Function;
}
const MenuLayout = (
TagsList: FC,
ShortUrlsList: FC,
@ -19,20 +24,29 @@ const MenuLayout = (
ShortUrlVisits: FC,
TagVisits: FC,
OrphanVisits: FC,
NonOrphanVisits: FC,
ServerError: FC,
Overview: FC,
EditShortUrl: FC,
ManageDomains: FC,
) => withSelectedServer(({ location, selectedServer }) => {
) => withSelectedServer<MenuLayoutProps>(({ selectedServer, sidebarNotPresent, sidebarPresent }) => {
const location = useLocation();
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
const showContent = isReachableServer(selectedServer);
useEffect(() => hideSidebar(), [ location ]);
useEffect(() => {
showContent && sidebarPresent();
if (!isReachableServer(selectedServer)) {
return () => sidebarNotPresent();
}, []);
if (!showContent) {
return <ServerError />;
}
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer);
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
@ -46,21 +60,23 @@ const MenuLayout = (
<AsideMenu selectedServer={selectedServer} showOnMobile={sidebarVisible} />
<div className="menu-layout__container" onClick={() => hideSidebar()}>
<div className="container-xl">
<Switch>
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
<Route exact path="/server/:serverId/overview" component={Overview} />
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrlsList} />
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
<Route path="/server/:serverId/short-code/:shortCode/edit" component={EditShortUrl} />
<Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />
{addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />}
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
{addManageDomainsRoute && <Route exact path="/server/:serverId/manage-domains" component={ManageDomains} />}
<Routes>
<Route index element={<Navigate replace to="overview" />} />
<Route path="/overview" element={<Overview />} />
<Route path="/list-short-urls/:page" element={<ShortUrlsList />} />
<Route path="/create-short-url" element={<CreateShortUrl />} />
<Route path="/short-code/:shortCode/visits/*" element={<ShortUrlVisits />} />
<Route path="/short-code/:shortCode/edit" element={<EditShortUrl />} />
<Route path="/tag/:tag/visits/*" element={<TagVisits />} />
{addOrphanVisitsRoute && <Route path="/orphan-visits/*" element={<OrphanVisits />} />}
{addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />}
<Route path="/manage-tags" element={<TagsList />} />
{addManageDomainsRoute && <Route path="/manage-domains" element={<ManageDomains />} />}
<Route
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
path="*"
element={<NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
/>
</Switch>
</Routes>
</div>
</div>
</div>

View file

@ -1,7 +1,9 @@
import { PropsWithChildren, useEffect } from 'react';
import { RouteComponentProps } from 'react-router';
import { FC, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
const ScrollToTop = (): FC => ({ children }) => {
const location = useLocation();
const ScrollToTop = () => ({ location, children }: PropsWithChildren<RouteComponentProps>) => {
useEffect(() => {
scrollTo(0, 0);
}, [ location ]);

View file

@ -1,13 +1,13 @@
import { pipe } from 'ramda';
import { ExternalLink } from 'react-external-link';
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
import { isReachableServer } from '../servers/data';
import { ShlinkVersionsContainerProps } from './ShlinkVersionsContainer';
import { isReachableServer, SelectedServer } from '../servers/data';
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
export interface ShlinkVersionsProps extends ShlinkVersionsContainerProps {
export interface ShlinkVersionsProps {
selectedServer: SelectedServer;
clientVersion?: string;
}

View file

@ -1,6 +1,6 @@
@import '../utils/base';
.shlink-versions-container--with-server {
.shlink-versions-container--with-sidebar {
margin-left: 0;
@media (min-width: $mdMin) {

View file

@ -1,15 +1,17 @@
import classNames from 'classnames';
import { isReachableServer, SelectedServer } from '../servers/data';
import { SelectedServer } from '../servers/data';
import ShlinkVersions from './ShlinkVersions';
import { Sidebar } from './reducers/sidebar';
import './ShlinkVersionsContainer.scss';
export interface ShlinkVersionsContainerProps {
selectedServer: SelectedServer;
sidebar: Sidebar;
}
const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => {
const ShlinkVersionsContainer = ({ selectedServer, sidebar }: ShlinkVersionsContainerProps) => {
const classes = classNames('text-center', {
'shlink-versions-container--with-server': isReachableServer(selectedServer),
'shlink-versions-container--with-sidebar': sidebar.sidebarPresent,
});
return (

View file

@ -0,0 +1,27 @@
import { Action } from 'redux';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
/* eslint-disable padding-line-between-statements */
export const SIDEBAR_PRESENT = 'shlink/common/SIDEBAR_PRESENT';
export const SIDEBAR_NOT_PRESENT = 'shlink/common/SIDEBAR_NOT_PRESENT';
/* eslint-enable padding-line-between-statements */
export interface Sidebar {
sidebarPresent: boolean;
}
type SidebarRenderedAction = Action<string>;
type SidebarNotRenderedAction = Action<string>;
const initialState: Sidebar = {
sidebarPresent: false,
};
export default buildReducer<Sidebar, SidebarRenderedAction & SidebarNotRenderedAction>({
[SIDEBAR_PRESENT]: () => ({ sidebarPresent: true }),
[SIDEBAR_NOT_PRESENT]: () => ({ sidebarPresent: false }),
}, initialState);
export const sidebarPresent = buildActionCreator(SIDEBAR_PRESENT);
export const sidebarNotPresent = buildActionCreator(SIDEBAR_NOT_PRESENT);

View file

@ -0,0 +1,33 @@
import { CsvJson } from 'csvjson';
import { NormalizedVisit } from '../../visits/types';
import { ExportableShortUrl } from '../../short-urls/data';
import { saveCsv } from '../../utils/helpers/files';
export class ReportExporter {
public constructor(
private readonly window: Window,
private readonly csvjson: CsvJson,
) {}
public readonly exportVisits = (filename: string, visits: NormalizedVisit[]) => {
if (!visits.length) {
return;
}
this.exportCsv(filename, visits);
};
public readonly exportShortUrls = (shortUrls: ExportableShortUrl[]) => {
if (!shortUrls.length) {
return;
}
this.exportCsv('short_urls.csv', shortUrls);
};
private readonly exportCsv = (filename: string, rows: object[]) => {
const csv = this.csvjson.toCSV(rows, { headers: 'key', wrap: true });
saveCsv(this.window, csv, filename);
};
}

View file

@ -1,5 +1,5 @@
import axios from 'axios';
import Bottle, { Decorator } from 'bottlejs';
import Bottle from 'bottlejs';
import ScrollToTop from '../ScrollToTop';
import MainHeader from '../MainHeader';
import Home from '../Home';
@ -9,26 +9,26 @@ import ErrorHandler from '../ErrorHandler';
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar';
import { ImageDownloader } from './ImageDownloader';
import { ReportExporter } from './ReportExporter';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Services
bottle.constant('window', (global as any).window);
bottle.constant('console', global.console);
bottle.constant('axios', axios);
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
bottle.service('ReportExporter', ReportExporter, 'window', 'csvjson');
// Components
bottle.serviceFactory('ScrollToTop', ScrollToTop);
bottle.decorator('ScrollToTop', withRouter);
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
bottle.decorator('MainHeader', withRouter);
bottle.serviceFactory('Home', () => Home);
bottle.decorator('Home', withoutSelectedServer);
bottle.decorator('Home', withRouter);
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
bottle.serviceFactory(
@ -41,20 +41,24 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'ShortUrlVisits',
'TagVisits',
'OrphanVisits',
'NonOrphanVisits',
'ServerError',
'Overview',
'EditShortUrl',
'ManageDomains',
);
bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer' ]));
bottle.decorator('MenuLayout', withRouter);
bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer', 'sidebarPresent', 'sidebarNotPresent' ]));
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer' ]));
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer', 'sidebar' ]));
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
// Actions
bottle.serviceFactory('sidebarPresent', () => sidebarPresent);
bottle.serviceFactory('sidebarNotPresent', () => sidebarNotPresent);
};
export default provideServices;

View file

@ -1,5 +1,4 @@
import Bottle, { IContainer } from 'bottlejs';
import { withRouter } from 'react-router-dom';
import { connect as reduxConnect } from 'react-redux';
import { pick } from 'ramda';
import provideApiServices from '../api/services/provideServices';
@ -34,11 +33,11 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
actionServiceNames.reduce(mapActionService, {}),
);
provideAppServices(bottle, connect, withRouter);
provideCommonServices(bottle, connect, withRouter);
provideAppServices(bottle, connect);
provideCommonServices(bottle, connect);
provideApiServices(bottle);
provideShortUrlsServices(bottle, connect, withRouter);
provideServersServices(bottle, connect, withRouter);
provideShortUrlsServices(bottle, connect);
provideServersServices(bottle, connect);
provideTagsServices(bottle, connect);
provideVisitsServices(bottle, connect);
provideUtilsServices(bottle);

View file

@ -14,6 +14,7 @@ import { TagVisits } from '../visits/reducers/tagVisits';
import { DomainsList } from '../domains/reducers/domainsList';
import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { VisitsInfo } from '../visits/types';
import { Sidebar } from '../common/reducers/sidebar';
export interface ShlinkState {
servers: ServersMap;
@ -25,6 +26,7 @@ export interface ShlinkState {
shortUrlVisits: ShortUrlVisits;
tagVisits: TagVisits;
orphanVisits: VisitsInfo;
nonOrphanVisits: VisitsInfo;
shortUrlDetail: ShortUrlDetail;
tagsList: TagsList;
tagDelete: TagDeletion;
@ -34,6 +36,7 @@ export interface ShlinkState {
domainsList: DomainsList;
visitsOverview: VisitsOverview;
appUpdated: boolean;
sidebar: Sidebar;
}
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;

View file

@ -63,7 +63,7 @@ export const DomainRow: FC<DomainRowProps> = (
<td className="responsive-table__cell text-lg-center" data-th="Status">
<DomainStatusIcon status={status} />
</td>
<td className="responsive-table__cell text-right">
<td className="responsive-table__cell text-end">
<span id={!canEditDomain ? 'defaultDomainBtn' : undefined}>
<Button outline size="sm" disabled={!canEditDomain} onClick={!canEditDomain ? undefined : toggle}>
<FontAwesomeIcon fixedWidth icon={!canEditDomain ? forbiddenIcon : editIcon} />

View file

@ -1,6 +1,5 @@
import { useEffect } from 'react';
import { Button, DropdownItem, Input, InputGroup, InputGroupAddon, UncontrolledTooltip } from 'reactstrap';
import { InputProps } from 'reactstrap/lib/Input';
import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip, InputProps } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUndo } from '@fortawesome/free-solid-svg-icons';
import { isEmpty, pipe } from 'ramda';
@ -32,24 +31,22 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
return inputDisplayed ? (
<InputGroup>
<Input
value={value}
value={value ?? ''}
placeholder="Domain"
onChange={(e) => onChange(e.target.value)}
/>
<InputGroupAddon addonType="append">
<Button
id="backToDropdown"
outline
type="button"
className="domains-dropdown__back-btn"
onClick={pipe(unselectDomain, hideInput)}
>
<FontAwesomeIcon icon={faUndo} />
</Button>
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
Existing domains
</UncontrolledTooltip>
</InputGroupAddon>
<Button
id="backToDropdown"
outline
type="button"
className="domains-dropdown__back-btn"
onClick={pipe(unselectDomain, hideInput)}
>
<FontAwesomeIcon icon={faUndo} />
</Button>
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
Existing domains
</UncontrolledTooltip>
</InputGroup>
) : (
<DropdownBtn
@ -63,7 +60,7 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
onClick={() => onChange(domain)}
>
{domain}
{isDefault && <span className="float-right text-muted">default</span>}
{isDefault && <span className="float-end text-muted">default</span>}
</DropdownItem>
))}
<DropdownItem divider />

View file

@ -45,7 +45,7 @@ export const ManageDomains: FC<ManageDomainsProps> = (
return (
<SimpleCard>
<table className="table table-hover mb-0">
<table className="table table-hover responsive-table mb-0">
<thead className="responsive-table__header">
<tr>{headers.map((column, index) => <th key={index}>{column}</th>)}</tr>
</thead>

View file

@ -1,7 +1,7 @@
import { FC, useState } from 'react';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer';
import { InputFormGroup, InputFormGroupProps } from '../../utils/forms/InputFormGroup';
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
import { InfoTooltip } from '../../utils/InfoTooltip';
@ -12,8 +12,8 @@ interface EditDomainRedirectsModalProps {
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
}
const FormGroup: FC<FormGroupContainerProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
<FormGroupContainer
const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
<InputFormGroup
{...rest}
required={false}
type="url"
@ -42,20 +42,20 @@ export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
<ModalHeader toggle={toggle}>Edit redirects for <b>{domain.domain}</b></ModalHeader>
<ModalBody>
<FormGroup value={baseUrlRedirect} onChange={setBaseUrlRedirect}>
<InfoTooltip className="mr-2" placement="bottom">
<InfoTooltip className="me-2" placement="bottom">
Visitors accessing the base url, as in <b>https://{domain.domain}/</b>, will be redirected to this URL.
</InfoTooltip>
Base URL
</FormGroup>
<FormGroup value={regular404Redirect} onChange={setRegular404Redirect}>
<InfoTooltip className="mr-2" placement="bottom">
<InfoTooltip className="me-2" placement="bottom">
Visitors accessing a url not matching a short URL pattern, as in <b>https://{domain.domain}/???/[...]</b>,
will be redirected to this URL.
</InfoTooltip>
Regular 404
</FormGroup>
<FormGroup value={invalidShortUrlRedirect} isLast onChange={setInvalidShortUrlRedirect}>
<InfoTooltip className="mr-2" placement="bottom">
<InfoTooltip className="me-2" placement="bottom">
Visitors accessing a url matching a short URL pattern, but not matching an existing short code, will be
redirected to this URL.
</InfoTooltip>

View file

@ -11,6 +11,10 @@
outline: none !important;
}
:root {
scroll-behavior: auto;
}
html,
body,
#root {
@ -19,6 +23,17 @@ body,
color: var(--text-color);
}
a,
.btn-link {
text-decoration: none;
}
/* stylelint-disable-next-line selector-max-pseudo-class */
a:not(.nav-link):not(.navbar-brand):not(.page-link):not(.highlight-card):not(.btn):not(.dropdown-item):hover,
.btn-link:hover {
text-decoration: underline;
}
.bg-main {
background-color: $mainColor !important;
}
@ -74,7 +89,8 @@ hr {
border-color: var(--table-border-color);
}
.page-link:hover {
.page-link:hover,
.page-link:focus {
background-color: var(--secondary-color);
}
@ -98,6 +114,22 @@ hr {
}
}
/* Deprecated. Brought from bootstrap 4 */
.btn-block {
display: block;
width: 100%;
}
.btn-primary,
.btn-primary:hover,
.btn-primary:active,
.btn-primary.active,
.btn-outline-primary:hover,
.btn-outline-primary:active,
.btn-outline-primary.active, {
color: #ffffff;
}
.dropdown-item,
.dropdown-item-text {
color: var(--text-color);
@ -133,10 +165,15 @@ hr {
.close,
.close:hover,
.table,
.table-hover tbody tr:hover {
.table-hover > tbody > tr:hover > *,
.table-hover > tbody > tr > * {
color: var(--text-color);
}
.btn-close {
filter: var(--btn-close-filter);
}
.table-hover tbody tr:hover {
background-color: var(--secondary-color);
}

View file

@ -1,5 +1,6 @@
import { FC, useEffect } from 'react';
import { pipe } from 'ramda';
import { useParams } from 'react-router-dom';
import { CreateVisit } from '../../visits/types';
import { MercureInfo } from '../reducers/mercureInfo';
import { bindToMercureTopic } from './index';
@ -12,17 +13,19 @@ export interface MercureBoundProps {
export function boundToMercureHub<T = {}>(
WrappedComponent: FC<MercureBoundProps & T>,
getTopicsForProps: (props: T) => string[],
getTopicsForProps: (props: T, routeParams: any) => string[],
) {
const pendingUpdates = new Set<CreateVisit>();
return (props: MercureBoundProps & T) => {
const { createNewVisits, loadMercureInfo, mercureInfo } = props;
const { interval } = mercureInfo;
const params = useParams();
useEffect(() => {
const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]);
const closeEventSource = bindToMercureTopic(mercureInfo, getTopicsForProps(props), onMessage, loadMercureInfo);
const topics = getTopicsForProps(props, params);
const closeEventSource = bindToMercureTopic(mercureInfo, topics, onMessage, loadMercureInfo);
if (!interval) {
return closeEventSource;

View file

@ -8,6 +8,7 @@ import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
import tagVisitsReducer from '../visits/reducers/tagVisits';
import orphanVisitsReducer from '../visits/reducers/orphanVisits';
import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits';
import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail';
import tagsListReducer from '../tags/reducers/tagsList';
import tagDeleteReducer from '../tags/reducers/tagDelete';
@ -17,6 +18,7 @@ import settingsReducer from '../settings/reducers/settings';
import domainsListReducer from '../domains/reducers/domainsList';
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
import appUpdatesReducer from '../app/reducers/appUpdates';
import sidebarReducer from '../common/reducers/sidebar';
import { ShlinkState } from '../container/types';
export default combineReducers<ShlinkState>({
@ -29,6 +31,7 @@ export default combineReducers<ShlinkState>({
shortUrlVisits: shortUrlVisitsReducer,
tagVisits: tagVisitsReducer,
orphanVisits: orphanVisitsReducer,
nonOrphanVisits: nonOrphanVisitsReducer,
shortUrlDetail: shortUrlDetailReducer,
tagsList: tagsListReducer,
tagDelete: tagDeleteReducer,
@ -38,4 +41,5 @@ export default combineReducers<ShlinkState>({
domainsList: domainsListReducer,
visitsOverview: visitsOverviewReducer,
appUpdated: appUpdatesReducer,
sidebar: sidebarReducer,
});

View file

@ -1,10 +1,10 @@
import { FC, useEffect, useState } from 'react';
import { v4 as uuid } from 'uuid';
import { RouterProps } from 'react-router';
import { Button } from 'reactstrap';
import { useNavigate } from 'react-router-dom';
import { Result } from '../utils/Result';
import { NoMenuLayout } from '../common/NoMenuLayout';
import { StateFlagTimeout, useToggle } from '../utils/helpers/hooks';
import { StateFlagTimeout, useGoBack, useToggle } from '../utils/helpers/hooks';
import { ServerForm } from './helpers/ServerForm';
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServerData, ServersMap, ServerWithId } from './data';
@ -12,7 +12,7 @@ import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
const SHOW_IMPORT_MSG_TIME = 4000;
interface CreateServerProps extends RouterProps {
interface CreateServerProps {
createServer: (server: ServerWithId) => void;
servers: ServersMap;
}
@ -27,8 +27,10 @@ const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
);
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
{ servers, createServer, history: { push, goBack } }: CreateServerProps,
{ servers, createServer }: CreateServerProps,
) => {
const navigate = useNavigate();
const goBack = useGoBack();
const hasServers = !!Object.keys(servers).length;
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
@ -42,7 +44,7 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
const id = uuid();
createServer({ ...serverData, id });
push(`/server/${id}`);
navigate(`/server/${id}`);
};
useEffect(() => {
@ -59,7 +61,7 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
{!hasServers &&
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />}
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
<Button outline color="primary" className="ml-2">Create server</Button>
<Button outline color="primary" className="ms-2">Create server</Button>
</ServerForm>
{serversImported && <ImportResult type="success" />}

View file

@ -1,6 +1,6 @@
import { FC } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { RouterProps } from 'react-router';
import { useNavigate } from 'react-router-dom';
import { ServerWithId } from './data';
export interface DeleteServerModalProps {
@ -10,17 +10,18 @@ export interface DeleteServerModalProps {
redirectHome?: boolean;
}
interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps {
interface DeleteServerModalConnectProps extends DeleteServerModalProps {
deleteServer: (server: ServerWithId) => void;
}
const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
{ server, toggle, isOpen, deleteServer, history, redirectHome = true },
{ server, toggle, isOpen, deleteServer, redirectHome = true },
) => {
const navigate = useNavigate();
const closeModal = () => {
deleteServer(server);
toggle();
redirectHome && history.push('/');
redirectHome && navigate('/');
};
return (

View file

@ -1,6 +1,7 @@
import { FC } from 'react';
import { Button } from 'reactstrap';
import { NoMenuLayout } from '../common/NoMenuLayout';
import { useGoBack } from '../utils/helpers/hooks';
import { ServerForm } from './helpers/ServerForm';
import { withSelectedServer } from './helpers/withSelectedServer';
import { isServerWithId, ServerData } from './data';
@ -9,9 +10,9 @@ interface EditServerProps {
editServer: (serverId: string, serverData: ServerData) => void;
}
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
{ editServer, selectedServer, history: { goBack } },
) => {
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>(({ editServer, selectedServer }) => {
const goBack = useGoBack();
if (!isServerWithId(selectedServer)) {
return null;
}
@ -28,7 +29,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
initialValues={selectedServer}
onSubmit={handleSubmit}
>
<Button outline className="mr-2" onClick={goBack}>Cancel</Button>
<Button outline className="me-2" onClick={goBack}>Cancel</Button>
<Button outline color="primary">Save</Button>
</ServerForm>
</NoMenuLayout>

View file

@ -45,12 +45,12 @@ export const ManageServers = (
<div className="col-md-6 d-flex d-md-block mb-2 mb-md-0">
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
{allServers.length > 0 && (
<Button outline className="ml-2 flex-fill" onClick={async () => serversExporter.exportServers()}>
<Button outline className="ms-2 flex-fill" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
</Button>
)}
</div>
<div className="col-md-6 text-md-right d-flex d-md-block">
<div className="col-md-6 text-md-end d-flex d-md-block">
<Button outline color="primary" className="flex-fill" tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
</Button>
@ -58,7 +58,7 @@ export const ManageServers = (
</Row>
<SimpleCard>
<table className="table table-hover mb-0">
<table className="table table-hover responsive-table mb-0">
<thead className="responsive-table__header">
<tr>
{hasAutoConnect && <th style={{ width: '50px' }} />}

View file

@ -31,7 +31,7 @@ export const ManageServersRow = (
<Link to={`/server/${server.id}`}>{server.name}</Link>
</th>
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
<td className="responsive-table__cell text-right">
<td className="responsive-table__cell text-end">
<ManageServersRowDropdown server={server} />
</td>
</tr>

View file

@ -1,13 +0,0 @@
@import '../utils/base';
.overview__card.overview__card {
text-align: center;
border-top: 3px solid var(--brand-color);
color: inherit;
text-decoration: none;
}
.overview__card-title {
text-transform: uppercase;
color: $textPlaceholder;
}

View file

@ -1,6 +1,6 @@
import { FC, useEffect } from 'react';
import { Card, CardBody, CardHeader, CardText, CardTitle, Row } from 'reactstrap';
import { Link, useHistory } from 'react-router-dom';
import { Card, CardBody, CardHeader, Row } from 'reactstrap';
import { Link, useNavigate } from 'react-router-dom';
import { ITEMS_IN_OVERVIEW_PAGE, ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
import { prettify } from '../utils/helpers/numbers';
import { TagsList } from '../tags/reducers/tagsList';
@ -11,8 +11,9 @@ import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { Versions } from '../utils/helpers/version';
import { Topics } from '../mercure/helpers/Topics';
import { ShlinkShortUrlsListParams } from '../api/types';
import { supportsNonOrphanVisits, supportsOrphanVisits } from '../utils/helpers/features';
import { getServerId, SelectedServer } from './data';
import './Overview.scss';
import { HighlightCard } from './helpers/HighlightCard';
interface OverviewConnectProps {
shortUrlsList: ShortUrlsListState;
@ -41,7 +42,9 @@ export const Overview = (
const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
const serverId = getServerId(selectedServer);
const history = useHistory();
const linkToOrphanVisits = supportsOrphanVisits(selectedServer);
const linkToNonOrphanVisits = supportsNonOrphanVisits(selectedServer);
const navigate = useNavigate();
useEffect(() => {
listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } });
@ -52,45 +55,38 @@ export const Overview = (
return (
<>
<Row>
<div className="col-md-6 col-xl-3">
<Card className="overview__card mb-3" body>
<CardTitle tag="h5" className="overview__card-title">Visits</CardTitle>
<CardText tag="h2">{loadingVisits ? 'Loading...' : prettify(visitsCount)}</CardText>
</Card>
<div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Visits" link={linkToNonOrphanVisits && `/server/${serverId}/non-orphan-visits`}>
{loadingVisits ? 'Loading...' : prettify(visitsCount)}
</HighlightCard>
</div>
<div className="col-md-6 col-xl-3">
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/orphan-visits`}>
<CardTitle tag="h5" className="overview__card-title">Orphan visits</CardTitle>
<CardText tag="h2">
<ForServerVersion minVersion="2.6.0">
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)}
</ForServerVersion>
<ForServerVersion maxVersion="2.5.*">
<small className="text-muted"><i>Shlink 2.6 is needed</i></small>
</ForServerVersion>
</CardText>
</Card>
<div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Orphan visits" link={linkToOrphanVisits && `/server/${serverId}/orphan-visits`}>
<ForServerVersion minVersion="2.6.0">
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)}
</ForServerVersion>
<ForServerVersion maxVersion="2.5.*">
<small className="text-muted"><i>Shlink 2.6 is needed</i></small>
</ForServerVersion>
</HighlightCard>
</div>
<div className="col-md-6 col-xl-3">
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/list-short-urls/1`}>
<CardTitle tag="h5" className="overview__card-title">Short URLs</CardTitle>
<CardText tag="h2">
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
</CardText>
</Card>
<div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}>
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
</HighlightCard>
</div>
<div className="col-md-6 col-xl-3">
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/manage-tags`}>
<CardTitle tag="h5" className="overview__card-title">Tags</CardTitle>
<CardText tag="h2">{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}</CardText>
</Card>
<div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Tags" link={`/server/${serverId}/manage-tags`}>
{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}
</HighlightCard>
</div>
</Row>
<Card className="mb-3">
<CardHeader>
<span className="d-sm-none">Create a short URL</span>
<h5 className="d-none d-sm-inline">Create a short URL</h5>
<Link className="float-right" to={`/server/${serverId}/create-short-url`}>Advanced options &raquo;</Link>
<Link className="float-end" to={`/server/${serverId}/create-short-url`}>Advanced options &raquo;</Link>
</CardHeader>
<CardBody>
<CreateShortUrl basicMode />
@ -100,14 +96,14 @@ export const Overview = (
<CardHeader>
<span className="d-sm-none">Recently created URLs</span>
<h5 className="d-none d-sm-inline">Recently created URLs</h5>
<Link className="float-right" to={`/server/${serverId}/list-short-urls/1`}>See all &raquo;</Link>
<Link className="float-end" to={`/server/${serverId}/list-short-urls/1`}>See all &raquo;</Link>
</CardHeader>
<CardBody>
<ShortUrlsTable
shortUrlsList={shortUrlsList}
selectedServer={selectedServer}
className="mb-0"
onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
onTagClick={(tag) => navigate(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
/>
</CardBody>
</Card>

View file

@ -17,7 +17,7 @@ const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
if (isEmpty(serversList)) {
return (
<DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add a server</span>
<FontAwesomeIcon icon={plusIcon} /> <span className="ms-1">Add a server</span>
</DropdownItem>
);
}
@ -31,7 +31,7 @@ const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
))}
<DropdownItem divider />
<DropdownItem tag={Link} to="/manage-servers">
<FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Manage servers</span>
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Manage servers</span>
</DropdownItem>
</>
);
@ -40,9 +40,9 @@ const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
return (
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>
<FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Servers</span>
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Servers</span>
</DropdownToggle>
<DropdownMenu right>{renderServers()}</DropdownMenu>
<DropdownMenu end style={{ right: 0 }}>{renderServers()}</DropdownMenu>
</UncontrolledDropdown>
);
};

View file

@ -17,6 +17,10 @@
padding: .75rem 2.5rem .75rem 1rem;
}
.servers-list__server-item:not(:hover) {
color: $mainColor;
}
.servers-list__server-item:hover {
background-color: var(--secondary-color);
}

View file

@ -0,0 +1,21 @@
@import '../../utils/base';
.highlight-card.highlight-card {
text-align: center;
border-top: 3px solid var(--brand-color);
color: inherit;
text-decoration: none;
}
.highlight-card__link-icon {
position: absolute;
right: 5px;
bottom: 5px;
opacity: 0.1;
transform: rotate(-45deg);
}
.highlight-card__title {
text-transform: uppercase;
color: $textPlaceholder;
}

View file

@ -0,0 +1,21 @@
import { FC } from 'react';
import { Card, CardText, CardTitle } from 'reactstrap';
import { Link } from 'react-router-dom';
import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import './HighlightCard.scss';
export interface HighlightCardProps {
title: string;
link?: string | false;
}
const buildExtraProps = (link?: string | false) => !link ? {} : { tag: Link, to: link };
export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link }) => (
<Card className="highlight-card" body {...buildExtraProps(link)}>
{link && <FontAwesomeIcon size="3x" className="highlight-card__link-icon" icon={linkIcon} />}
<CardTitle tag="h5" className="highlight-card__title">{title}</CardTitle>
<CardText tag="h2">{children}</CardText>
</Card>
);

View file

@ -1,10 +0,0 @@
@import '../../utils/base';
.server-form .form-group:last-child {
margin-bottom: 0;
}
.server-form__label {
font-weight: 700;
cursor: pointer;
}

View file

@ -1,9 +1,8 @@
import { FC, ReactNode, useEffect, useState } from 'react';
import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer';
import { InputFormGroup } from '../../utils/forms/InputFormGroup';
import { handleEventPreventingDefault } from '../../utils/utils';
import { ServerData } from '../data';
import { SimpleCard } from '../../utils/SimpleCard';
import './ServerForm.scss';
interface ServerFormProps {
onSubmit: (server: ServerData) => void;
@ -11,9 +10,6 @@ interface ServerFormProps {
title?: ReactNode;
}
const FormGroup: FC<FormGroupContainerProps> = (props) =>
<FormGroupContainer {...props} labelClassName="server-form__label" />;
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
const [ name, setName ] = useState('');
const [ url, setUrl ] = useState('');
@ -29,12 +25,12 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
return (
<form className="server-form" onSubmit={handleSubmit}>
<SimpleCard className="mb-3" title={title}>
<FormGroup value={name} onChange={setName}>Name</FormGroup>
<FormGroup type="url" value={url} onChange={setUrl}>URL</FormGroup>
<FormGroup value={apiKey} onChange={setApiKey}>API key</FormGroup>
<InputFormGroup value={name} onChange={setName}>Name</InputFormGroup>
<InputFormGroup type="url" value={url} onChange={setUrl}>URL</InputFormGroup>
<InputFormGroup value={apiKey} onChange={setApiKey}>API key</InputFormGroup>
</SimpleCard>
<div className="text-right">{children}</div>
<div className="text-end">{children}</div>
</form>
);
};

View file

@ -1,21 +1,22 @@
import { FC, useEffect } from 'react';
import { RouteComponentProps } from 'react-router';
import { useParams } from 'react-router-dom';
import Message from '../../utils/Message';
import { isNotFoundServer, SelectedServer } from '../data';
import { NoMenuLayout } from '../../common/NoMenuLayout';
interface WithSelectedServerProps extends RouteComponentProps<{ serverId: string }> {
interface WithSelectedServerProps {
selectServer: (serverId: string) => void;
selectedServer: SelectedServer;
}
export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServerProps & T>, ServerError: FC) {
return (props: WithSelectedServerProps & T) => {
const { selectServer, selectedServer, match } = props;
const params = useParams<{ serverId: string }>();
const { selectServer, selectedServer } = props;
useEffect(() => {
selectServer(match.params.serverId);
}, [ match.params.serverId ]);
params.serverId && selectServer(params.serverId);
}, [ params.serverId ]);
if (!selectedServer) {
return (

View file

@ -1,5 +1,5 @@
import csvjson from 'csvjson';
import Bottle, { Decorator } from 'bottlejs';
import Bottle from 'bottlejs';
import CreateServer from '../CreateServer';
import ServersDropdown from '../ServersDropdown';
import DeleteServerModal from '../DeleteServerModal';
@ -20,7 +20,7 @@ import { ManageServersRowDropdown } from '../ManageServersRowDropdown';
import { ServersImporter } from './ServersImporter';
import ServersExporter from './ServersExporter';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory(
'ManageServers',
@ -30,7 +30,8 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'useStateFlagTimeout',
'ManageServersRow',
);
bottle.decorator('ManageServers', connect([ 'servers' ]));
bottle.decorator('ManageServers', withoutSelectedServer);
bottle.decorator('ManageServers', connect([ 'selectedServer', 'servers' ], [ 'resetSelectedServer' ]));
bottle.serviceFactory('ManageServersRow', ManageServersRow, 'ManageServersRowDropdown');
@ -42,13 +43,12 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
bottle.decorator('CreateServer', connect([ 'selectedServer', 'servers' ], [ 'createServer', 'resetSelectedServer' ]));
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer', 'resetSelectedServer' ]));
bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
bottle.decorator('DeleteServerModal', withRouter);
bottle.decorator('DeleteServerModal', connect(null, [ 'deleteServer' ]));
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');

View file

@ -2,6 +2,8 @@ import { FormGroup, Input } from 'reactstrap';
import classNames from 'classnames';
import ToggleSwitch from '../utils/ToggleSwitch';
import { SimpleCard } from '../utils/SimpleCard';
import { FormText } from '../utils/forms/FormText';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings } from './reducers/settings';
interface RealTimeUpdatesProps {
@ -19,15 +21,16 @@ const RealTimeUpdatesSettings = (
<FormGroup>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
Enable or disable real-time updates.
<small className="form-text text-muted">
<FormText>
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
</small>
</FormText>
</ToggleSwitch>
</FormGroup>
<FormGroup className="mb-0">
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
Real-time updates frequency (in minutes):
</label>
<LabeledFormGroup
noMargin
label="Real-time updates frequency (in minutes):"
labelClassName={classNames('form-label', { 'text-muted': !realTimeUpdates.enabled })}
>
<Input
type="number"
min={0}
@ -37,16 +40,16 @@ const RealTimeUpdatesSettings = (
onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))}
/>
{realTimeUpdates.enabled && (
<small className="form-text text-muted">
<FormText>
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
<span>
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
</span>
)}
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
</small>
</FormText>
)}
</FormGroup>
</LabeledFormGroup>
</SimpleCard>
);

View file

@ -1,18 +1,11 @@
import { FC, ReactNode } from 'react';
import { Row } from 'reactstrap';
import { Navigate, Routes, Route } from 'react-router-dom';
import { NoMenuLayout } from '../common/NoMenuLayout';
import { NavPillItem, NavPills } from '../utils/NavPills';
const SettingsSections: FC<{ items: ReactNode[][] }> = ({ items }) => (
const SettingsSections: FC<{ items: ReactNode[] }> = ({ items }) => (
<>
{items.map((child, index) => (
<Row key={index}>
{child.map((subChild, subIndex) => (
<div key={subIndex} className={`col-lg-${12 / child.length} mb-3`}>
{subChild}
</div>
))}
</Row>
))}
{items.map((child, index) => <div key={index} className="mb-3">{child}</div>)}
</>
);
@ -25,13 +18,18 @@ const Settings = (
Tags: FC,
) => () => (
<NoMenuLayout>
<SettingsSections
items={[
[ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key
[ <ShortUrlCreation />, <ShortUrlsList /> ], // eslint-disable-line react/jsx-key
[ <Tags />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
]}
/>
<NavPills className="mb-3">
<NavPillItem to="general">General</NavPillItem>
<NavPillItem to="short-urls">Short URLs</NavPillItem>
<NavPillItem to="other-items">Other items</NavPillItem>
</NavPills>
<Routes>
<Route path="general" element={<SettingsSections items={[ <UserInterface key="one" />, <RealTimeUpdates key="two" /> ]} />} />
<Route path="short-urls" element={<SettingsSections items={[ <ShortUrlCreation key="one" />, <ShortUrlsList key="two" /> ]} />} />
<Route path="other-items" element={<SettingsSections items={[ <Tags key="one" />, <Visits key="two" /> ]} />} />
<Route path="*" element={<Navigate replace to="general" />} />
</Routes>
</NoMenuLayout>
);

View file

@ -3,6 +3,8 @@ import { DropdownItem, FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
import ToggleSwitch from '../utils/ToggleSwitch';
import { DropdownBtn } from '../utils/DropdownBtn';
import { FormText } from '../utils/forms/FormText';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings, ShortUrlCreationSettings as ShortUrlsSettings, TagFilteringMode } from './reducers/settings';
interface ShortUrlCreationProps {
@ -31,10 +33,10 @@ export const ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings,
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
>
Request validation on long URLs when creating new short URLs.
<small className="form-text text-muted">
<FormText>
The initial state of the <b>Validate URL</b> checkbox will
be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>.
</small>
</FormText>
</ToggleSwitch>
</FormGroup>
<FormGroup>
@ -43,14 +45,13 @@ export const ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings,
onChange={(forwardQuery) => setShortUrlCreationSettings({ ...shortUrlCreation, forwardQuery })}
>
Make all new short URLs forward their query params to the long URL.
<small className="form-text text-muted">
<FormText>
The initial state of the <b>Forward query params on redirect</b> checkbox will
be <b>{shortUrlCreation.forwardQuery ?? true ? 'checked' : 'unchecked'}</b>.
</small>
</FormText>
</ToggleSwitch>
</FormGroup>
<FormGroup className="mb-0">
<label>Tag suggestions search mode:</label>
<LabeledFormGroup noMargin label="Tag suggestions search mode:">
<DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}>
<DropdownItem
active={!shortUrlCreation.tagFilteringMode || shortUrlCreation.tagFilteringMode === 'startsWith'}
@ -65,10 +66,8 @@ export const ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings,
{tagFilteringModeText('includes')}
</DropdownItem>
</DropdownBtn>
<small className="form-text text-muted">
{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}
</small>
</FormGroup>
<FormText>{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}</FormText>
</LabeledFormGroup>
</SimpleCard>
);
};

View file

@ -1,26 +1,25 @@
import { FC } from 'react';
import { FormGroup } from 'reactstrap';
import { OrderingDropdown } from '../utils/OrderingDropdown';
import { SHORT_URLS_ORDERABLE_FIELDS } from '../short-urls/data';
import { SimpleCard } from '../utils/SimpleCard';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings as ShortUrlsSettings } from './reducers/settings';
interface ShortUrlsListProps {
interface ShortUrlsListSettingsProps {
settings: Settings;
setShortUrlsListSettings: (settings: ShortUrlsSettings) => void;
}
export const ShortUrlsListSettings: FC<ShortUrlsListProps> = (
export const ShortUrlsListSettings: FC<ShortUrlsListSettingsProps> = (
{ settings: { shortUrlsList }, setShortUrlsListSettings },
) => (
<SimpleCard title="Short URLs list" className="h-100">
<FormGroup className="mb-0">
<label>Default ordering for short URLs list:</label>
<LabeledFormGroup noMargin label="Default ordering for short URLs list:">
<OrderingDropdown
items={SHORT_URLS_ORDERABLE_FIELDS}
order={shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING}
onChange={(field, dir) => setShortUrlsListSettings({ defaultOrdering: { field, dir } })}
/>
</FormGroup>
</LabeledFormGroup>
</SimpleCard>
);

View file

@ -1,10 +1,11 @@
import { FC } from 'react';
import { FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
import { capitalize } from '../utils/utils';
import { OrderingDropdown } from '../utils/OrderingDropdown';
import { TAGS_ORDERABLE_FIELDS } from '../tags/data/TagsListChildrenProps';
import { FormText } from '../utils/forms/FormText';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings, TagsSettings as TagsSettingsOptions } from './reducers/settings';
interface TagsProps {
@ -14,22 +15,20 @@ interface TagsProps {
export const TagsSettings: FC<TagsProps> = ({ settings: { tags }, setTagsSettings }) => (
<SimpleCard title="Tags" className="h-100">
<FormGroup>
<label>Default display mode when managing tags:</label>
<LabeledFormGroup label="Default display mode when managing tags:">
<TagsModeDropdown
mode={tags?.defaultMode ?? 'cards'}
renderTitle={(tagsMode) => capitalize(tagsMode)}
onChange={(defaultMode) => setTagsSettings({ ...tags, defaultMode })}
/>
<small className="form-text text-muted">Tags will be displayed as <b>{tags?.defaultMode ?? 'cards'}</b>.</small>
</FormGroup>
<FormGroup className="mb-0">
<label>Default ordering for tags list:</label>
<FormText>Tags will be displayed as <b>{tags?.defaultMode ?? 'cards'}</b>.</FormText>
</LabeledFormGroup>
<LabeledFormGroup noMargin label="Default ordering for tags list:">
<OrderingDropdown
items={TAGS_ORDERABLE_FIELDS}
order={tags?.defaultOrdering ?? {}}
onChange={(field, dir) => setTagsSettings({ ...tags, defaultOrdering: { field, dir } })}
/>
</FormGroup>
</LabeledFormGroup>
</SimpleCard>
);

View file

@ -1,7 +1,6 @@
import { FC } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
import { FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
import ToggleSwitch from '../utils/ToggleSwitch';
import { changeThemeInMarkup, Theme } from '../utils/theme';
@ -15,19 +14,17 @@ interface UserInterfaceProps {
export const UserInterfaceSettings: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
<SimpleCard title="User interface" className="h-100">
<FormGroup>
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
<ToggleSwitch
checked={ui?.theme === 'dark'}
onChange={(useDarkTheme) => {
const theme: Theme = useDarkTheme ? 'dark' : 'light';
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
<ToggleSwitch
checked={ui?.theme === 'dark'}
onChange={(useDarkTheme) => {
const theme: Theme = useDarkTheme ? 'dark' : 'light';
setUiSettings({ ...ui, theme });
changeThemeInMarkup(theme);
}}
>
Use dark theme.
</ToggleSwitch>
</FormGroup>
setUiSettings({ ...ui, theme });
changeThemeInMarkup(theme);
}}
>
Use dark theme.
</ToggleSwitch>
</SimpleCard>
);

View file

@ -1,7 +1,7 @@
import { FormGroup } from 'reactstrap';
import { FC } from 'react';
import { SimpleCard } from '../utils/SimpleCard';
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings, VisitsSettings as VisitsSettingsConfig } from './reducers/settings';
interface VisitsProps {
@ -11,13 +11,12 @@ interface VisitsProps {
export const VisitsSettings: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
<SimpleCard title="Visits" className="h-100">
<FormGroup className="mb-0">
<label>Default interval to load on visits sections:</label>
<LabeledFormGroup noMargin label="Default interval to load on visits sections:">
<DateIntervalSelector
allText="All visits"
active={settings.visits?.defaultInterval ?? 'last30Days'}
onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
/>
</FormGroup>
</LabeledFormGroup>
</SimpleCard>
);

View file

@ -1,9 +1,9 @@
import { FC, useEffect, useMemo } from 'react';
import { RouteComponentProps } from 'react-router';
import { Button, Card } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { ExternalLink } from 'react-external-link';
import { useLocation, useParams } from 'react-router-dom';
import { SelectedServer } from '../servers/data';
import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings';
import { OptionalString } from '../utils/utils';
@ -11,13 +11,13 @@ import { parseQuery } from '../utils/helpers/query';
import Message from '../utils/Message';
import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError';
import { useToggle } from '../utils/helpers/hooks';
import { useGoBack, useToggle } from '../utils/helpers/hooks';
import { ShortUrlFormProps } from './ShortUrlForm';
import { ShortUrlDetail } from './reducers/shortUrlDetail';
import { EditShortUrlData, ShortUrl, ShortUrlData } from './data';
import { ShortUrlEdition } from './reducers/shortUrlEdition';
interface EditShortUrlConnectProps extends RouteComponentProps<{ shortCode: string }> {
interface EditShortUrlConnectProps {
settings: Settings;
selectedServer: SelectedServer;
shortUrlDetail: ShortUrlDetail;
@ -48,9 +48,6 @@ const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSetting
};
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
history: { goBack },
match: { params },
location: { search },
settings: { shortUrlCreation: shortUrlCreationSettings },
selectedServer,
shortUrlDetail,
@ -58,6 +55,9 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
shortUrlEdition,
editShortUrl,
}: EditShortUrlConnectProps) => {
const { search } = useLocation();
const params = useParams<{ shortCode: string }>();
const goBack = useGoBack();
const { loading, error, errorData, shortUrl } = shortUrlDetail;
const { saving, error: savingError, errorData: savingErrorData } = shortUrlEdition;
const { domain } = parseQuery<{ domain?: string }>(search);
@ -68,7 +68,7 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
const [ savingSucceeded,, isSuccessful, isNotSuccessful ] = useToggle();
useEffect(() => {
getShortUrlDetail(params.shortCode, domain);
params.shortCode && getShortUrlDetail(params.shortCode, domain);
}, []);
if (loading) {
@ -88,7 +88,7 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
<header className="mb-3">
<Card body>
<h2 className="d-sm-flex justify-content-between align-items-center mb-0">
<Button color="link" size="lg" className="p-0 mr-3" onClick={goBack}>
<Button color="link" size="lg" className="p-0 me-3" onClick={goBack}>
<FontAwesomeIcon icon={faArrowLeft} />
</Button>
<span className="text-center">

View file

@ -1,6 +1,5 @@
@import '../utils/base';
.short-url-form .card-body > .form-group:last-child,
.short-url-form p:last-child {
margin-bottom: 0;
}

View file

@ -1,5 +1,5 @@
import { FC, useEffect, useState } from 'react';
import { InputType } from 'reactstrap/lib/Input';
import { InputType } from 'reactstrap/types/lib/Input';
import { Button, FormGroup, Input, Row } from 'reactstrap';
import { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
import classNames from 'classnames';
@ -86,15 +86,13 @@ export const ShortUrlForm = (
</FormGroup>
);
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
<div className="form-group">
<DateInput
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
placeholderText={placeholder}
isClearable
onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })}
{...props}
/>
</div>
<DateInput
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
placeholderText={placeholder}
isClearable
onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })}
{...props}
/>
);
const basicComponents = (
<>
@ -110,9 +108,9 @@ export const ShortUrlForm = (
</FormGroup>
<Row>
{isBasicMode && renderOptionalInput('customSlug', 'Custom slug', 'text', { bsSize: 'lg' }, { className: 'col-lg-6' })}
<FormGroup className={isBasicMode ? 'col-lg-6' : 'col-12 mb-0'}>
<div className={isBasicMode ? 'col-lg-6' : 'col-12 mb-0'}>
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
</FormGroup>
</div>
</Row>
</>
);
@ -154,12 +152,10 @@ export const ShortUrlForm = (
})}
</div>
</Row>
<FormGroup>
<DomainSelector
value={shortUrlData.domain}
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
/>
</FormGroup>
<DomainSelector
value={shortUrlData.domain}
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
/>
</>
)}
</SimpleCard>
@ -169,7 +165,9 @@ export const ShortUrlForm = (
<div className={limitAccessCardClasses}>
<SimpleCard title="Limit access to the short URL">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? toDate(shortUrlData.validUntil) : undefined })}
<div className="mb-3">
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? toDate(shortUrlData.validUntil) : undefined })}
</div>
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? toDate(shortUrlData.validSince) : undefined })}
</SimpleCard>
</div>
@ -189,7 +187,7 @@ export const ShortUrlForm = (
<p>
<Checkbox
inline
className="mr-2"
className="me-2"
checked={shortUrlData.findIfExists}
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
>

View file

@ -1,24 +1,41 @@
import { FC } from 'react';
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, pipe } from 'ramda';
import { parseISO } from 'date-fns';
import { RouteChildrenProps } from 'react-router-dom';
import { Row } from 'reactstrap';
import classNames from 'classnames';
import SearchField from '../utils/SearchField';
import Tag from '../tags/helpers/Tag';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
import { formatIsoDate } from '../utils/helpers/date';
import ColorGenerator from '../utils/services/ColorGenerator';
import { DateRange } from '../utils/dates/types';
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
import { supportsAllTagsFiltering } from '../utils/helpers/features';
import { SelectedServer } from '../servers/data';
import { TooltipToggleSwitch } from '../utils/TooltipToggleSwitch';
import { OrderDir } from '../utils/helpers/ordering';
import { OrderingDropdown } from '../utils/OrderingDropdown';
import { useShortUrlsQuery } from './helpers/hooks';
import { SHORT_URLS_ORDERABLE_FIELDS, ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
import './ShortUrlsFilteringBar.scss';
export type ShortUrlsFilteringProps = RouteChildrenProps<ShortUrlListRouteParams>;
export interface ShortUrlsFilteringProps {
selectedServer: SelectedServer;
order: ShortUrlsOrder;
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
className?: string;
shortUrlsAmount?: number;
}
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => (props: ShortUrlsFilteringProps) => {
const [{ search, tags, startDate, endDate }, toFirstPage ] = useShortUrlsQuery(props);
const selectedTags = tags?.split(',') ?? [];
const ShortUrlsFilteringBar = (
colorGenerator: ColorGenerator,
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => {
const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage ] = useShortUrlsQuery();
const setDates = pipe(
({ startDate, endDate }: DateRange) => ({
startDate: formatIsoDate(startDate) ?? undefined,
@ -31,35 +48,53 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => (props: ShortU
(search) => toFirstPage({ search }),
);
const removeTag = pipe(
(tag: string) => selectedTags.filter((selectedTag) => selectedTag !== tag),
(tagsList) => tagsList.length === 0 ? undefined : tagsList.join(','),
(tags) => toFirstPage({ tags }),
(tag: string) => tags.filter((selectedTag) => selectedTag !== tag),
(updateTags) => toFirstPage({ tags: updateTags }),
);
const canChangeTagsMode = supportsAllTagsFiltering(selectedServer);
const toggleTagsMode = pipe(
() => tagsMode === 'any' ? 'all' : 'any',
(tagsMode) => toFirstPage({ tagsMode }),
);
return (
<div className="short-urls-filtering-bar-container">
<div className={classNames('short-urls-filtering-bar-container', className)}>
<SearchField initialValue={search} onChange={setSearch} />
<div className="mt-3">
<div className="row">
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
<DateRangeSelector
defaultText="All short URLs"
initialDateRange={{
startDate: dateOrNull(startDate),
endDate: dateOrNull(endDate),
}}
onDatesChange={setDates}
/>
</div>
<Row className="flex-column-reverse flex-lg-row">
<div className="col-lg-4 col-xl-6 mt-3">
<ExportShortUrlsBtn amount={shortUrlsAmount} />
</div>
</div>
<div className="col-12 d-block d-lg-none mt-3">
<OrderingDropdown items={SHORT_URLS_ORDERABLE_FIELDS} order={order} onChange={handleOrderBy} />
</div>
<div className="col-lg-8 col-xl-6 mt-3">
<DateRangeSelector
defaultText="All short URLs"
initialDateRange={{
startDate: dateOrNull(startDate),
endDate: dateOrNull(endDate),
}}
onDatesChange={setDates}
/>
</div>
</Row>
{selectedTags.length > 0 && (
<h4 className="short-urls-filtering-bar__selected-tag mt-3">
<FontAwesomeIcon icon={tagsIcon} className="short-urls-filtering-bar__tags-icon" />
&nbsp;
{selectedTags.map((tag) =>
{tags.length > 0 && (
<h4 className="mt-3">
{canChangeTagsMode && tags.length > 1 && (
<div className="float-end ms-2 mt-1">
<TooltipToggleSwitch
checked={tagsMode === 'all'}
tooltip={{ placement: 'left' }}
onChange={toggleTagsMode}
>
{tagsMode === 'all' ? 'Short URLs including all tags.' : 'Short URLs including any tag.'}
</TooltipToggleSwitch>
</div>
)}
<FontAwesomeIcon icon={tagsIcon} className="short-urls-filtering-bar__tags-icon me-1" />
{tags.map((tag) =>
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
</h4>
)}

View file

@ -1,8 +1,7 @@
import { pipe } from 'ramda';
import { FC, useEffect, useMemo, useState } from 'react';
import { RouteComponentProps } from 'react-router';
import { FC, useEffect, useState } from 'react';
import { Card } from 'reactstrap';
import { OrderingDropdown } from '../utils/OrderingDropdown';
import { useLocation, useParams } from 'react-router-dom';
import { determineOrderDir, OrderDir } from '../utils/helpers/ordering';
import { getServerId, SelectedServer } from '../servers/data';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
@ -13,32 +12,29 @@ import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/sett
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { ShortUrlsTableProps } from './ShortUrlsTable';
import Paginator from './Paginator';
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
import { ShortUrlsOrderableFields, SHORT_URLS_ORDERABLE_FIELDS } from './data';
import { useShortUrlsQuery } from './helpers/hooks';
import { ShortUrlsOrderableFields } from './data';
import { ShortUrlsFilteringProps } from './ShortUrlsFilteringBar';
interface ShortUrlsListProps extends RouteComponentProps<ShortUrlListRouteParams> {
interface ShortUrlsListProps {
selectedServer: SelectedServer;
shortUrlsList: ShortUrlsListState;
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
settings: Settings;
}
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, ShortUrlsFilteringBar: FC) => boundToMercureHub(({
listShortUrls,
match,
location,
history,
shortUrlsList,
selectedServer,
settings,
}: ShortUrlsListProps) => {
const ShortUrlsList = (
ShortUrlsTable: FC<ShortUrlsTableProps>,
ShortUrlsFilteringBar: FC<ShortUrlsFilteringProps>,
) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => {
const serverId = getServerId(selectedServer);
const [{ tags, search, startDate, endDate, orderBy }, toFirstPage ] = useShortUrlsQuery({ history, match, location });
const { page } = useParams();
const location = useLocation();
const [{ tags, search, startDate, endDate, orderBy, tagsMode }, toFirstPage ] = useShortUrlsQuery();
const [ actualOrderBy, setActualOrderBy ] = useState(
// This separated state handling is needed to be able to fall back to settings value, but only once when loaded
orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING,
);
const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]);
const { pagination } = shortUrlsList?.shortUrls ?? {};
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
toFirstPage({ orderBy: { field, dir } });
@ -49,27 +45,31 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, ShortUrlsFilteri
const renderOrderIcon = (field: ShortUrlsOrderableFields) =>
<TableOrderIcon currentOrder={actualOrderBy} field={field} />;
const addTag = pipe(
(newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','),
(tags) => toFirstPage({ tags }),
(newTag: string) => [ ...new Set([ ...tags, newTag ]) ],
(updatedTags) => toFirstPage({ tags: updatedTags }),
);
useEffect(() => {
listShortUrls({
page: match.params.page,
page,
searchTerm: search,
tags: selectedTags,
tags,
startDate,
endDate,
orderBy: actualOrderBy,
tagsMode,
});
}, [ match.params.page, search, selectedTags, startDate, endDate, actualOrderBy ]);
}, [ page, search, tags, startDate, endDate, actualOrderBy, tagsMode ]);
return (
<>
<div className="mb-3"><ShortUrlsFilteringBar /></div>
<div className="d-block d-lg-none mb-3">
<OrderingDropdown items={SHORT_URLS_ORDERABLE_FIELDS} order={actualOrderBy} onChange={handleOrderBy} />
</div>
<ShortUrlsFilteringBar
selectedServer={selectedServer}
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
order={actualOrderBy}
handleOrderBy={handleOrderBy}
className="mb-3"
/>
<Card body className="pb-1">
<ShortUrlsTable
selectedServer={selectedServer}

View file

@ -28,7 +28,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
const { error, loading, shortUrls } = shortUrlsList;
const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn });
const orderableColumnsClasses = classNames('short-urls-table__header-cell', actionableFieldClasses);
const tableClasses = classNames('table table-hover', className);
const tableClasses = classNames('table table-hover responsive-table', className);
const supportsTitle = supportsShortUrlTitle(selectedServer);
const renderShortUrls = () => {

View file

@ -63,3 +63,12 @@ export const SHORT_URLS_ORDERABLE_FIELDS = {
export type ShortUrlsOrderableFields = keyof typeof SHORT_URLS_ORDERABLE_FIELDS;
export type ShortUrlsOrder = Order<ShortUrlsOrderableFields>;
export interface ExportableShortUrl {
createdAt: string;
title: string;
shortUrl: string;
longUrl: string;
tags: string;
visits: number;
}

View file

@ -28,7 +28,7 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
if (error) {
return (
<Result type="error" className="mt-3">
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />}
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-end pointer" onClick={resetCreateShortUrl} />}
<ShlinkApiError errorData={errorData} fallbackMessage="An error occurred while creating the URL :(" />
</Result>
);
@ -42,7 +42,7 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
return (
<Result type="success" className="mt-3">
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />}
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-end pointer" onClick={resetCreateShortUrl} />}
<b>Great!</b> The short URL is <b>{shortUrl}</b>
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>

View file

@ -0,0 +1,61 @@
import { FC } from 'react';
import { ExportBtn } from '../../utils/ExportBtn';
import { useToggle } from '../../utils/helpers/hooks';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { isServerWithId, SelectedServer } from '../../servers/data';
import { ShortUrl } from '../data';
import { ReportExporter } from '../../common/services/ReportExporter';
import { useShortUrlsQuery } from './hooks';
export interface ExportShortUrlsBtnProps {
amount?: number;
}
interface ExportShortUrlsBtnConnectProps extends ExportShortUrlsBtnProps {
selectedServer: SelectedServer;
}
const itemsPerPage = 20;
export const ExportShortUrlsBtn = (
buildShlinkApiClient: ShlinkApiClientBuilder,
{ exportShortUrls }: ReportExporter,
): FC<ExportShortUrlsBtnConnectProps> => ({ amount = 0, selectedServer }) => {
const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery();
const [ loading,, startLoading, stopLoading ] = useToggle();
const exportAllUrls = async () => {
if (!isServerWithId(selectedServer)) {
return;
}
const totalPages = amount / itemsPerPage;
const { listShortUrls } = buildShlinkApiClient(selectedServer);
const loadAllUrls = async (page = 1): Promise<ShortUrl[]> => {
const { data } = await listShortUrls(
{ page: `${page}`, tags, searchTerm: search, startDate, endDate, orderBy, tagsMode, itemsPerPage },
);
if (page >= totalPages) {
return data;
}
// TODO Support paralelization
return data.concat(await loadAllUrls(page + 1));
};
startLoading();
const shortUrls = await loadAllUrls();
exportShortUrls(shortUrls.map((shortUrl) => ({
createdAt: shortUrl.dateCreated,
shortUrl: shortUrl.shortUrl,
longUrl: shortUrl.longUrl,
title: shortUrl.title ?? '',
tags: shortUrl.tags.join(','),
visits: shortUrl.visitsCount,
})));
stopLoading();
};
return <ExportBtn loading={loading} className="btn-md-block" amount={amount} onClick={exportAllUrls} />;
};

View file

@ -3,7 +3,6 @@ import { Modal, FormGroup, ModalBody, ModalHeader, Row, Button } from 'reactstra
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons';
import { ExternalLink } from 'react-external-link';
import classNames from 'classnames';
import { ShortUrlModalProps } from '../data';
import { SelectedServer } from '../../servers/data';
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
@ -56,10 +55,8 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Vers
</ModalHeader>
<ModalBody>
<Row>
<FormGroup
className={classNames({ 'col-md-4': willRenderThreeControls, 'col-md-6': !willRenderThreeControls })}
>
<label className="mb-0">Size: {size}px</label>
<FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}>
<label>Size: {size}px</label>
<input
type="range"
className="form-control-range"
@ -71,8 +68,8 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Vers
/>
</FormGroup>
{capabilities.marginIsSupported && (
<FormGroup className={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}>
<label className="mb-0">Margin: {margin}px</label>
<FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}>
<label>Margin: {margin}px</label>
<input
type="range"
className="form-control-range"
@ -106,7 +103,7 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Vers
color="primary"
onClick={async () => imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`)}
>
Download <FontAwesomeIcon icon={downloadIcon} className="ml-1" />
Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" />
</Button>
</div>
</ForServerVersion>

View file

@ -12,7 +12,7 @@ export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
{ children, infoTooltip, checked, onChange },
) => (
<p>
<Checkbox inline checked={checked} className={infoTooltip ? 'mr-2' : ''} onChange={onChange}>
<Checkbox inline checked={checked} className={infoTooltip ? 'me-2' : ''} onChange={onChange}>
{children}
</Checkbox>
{infoTooltip && <InfoTooltip placement="right">{infoTooltip}</InfoTooltip>}

View file

@ -59,7 +59,7 @@ const ShortUrlsRow = (
<span className="indivisible short-urls-row__cell--relative">
<ExternalLink href={shortUrl.shortUrl} />
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
<span className="badge badge-warning short-urls-row__copy-hint" hidden={!copiedToClipboard}>
<span className="badge bg-warning text-black short-urls-row__copy-hint" hidden={!copiedToClipboard}>
Copied short URL!
</span>
</span>
@ -73,7 +73,7 @@ const ShortUrlsRow = (
</td>
)}
<td className="responsive-table__cell short-urls-row__cell" data-th="Tags">{renderTags(shortUrl.tags)}</td>
<td className="responsive-table__cell short-urls-row__cell text-lg-right" data-th="Visits">
<td className="responsive-table__cell short-urls-row__cell text-lg-end" data-th="Visits">
<ShortUrlVisitsCount
visitsCount={shortUrl.visitsCount}
shortUrl={shortUrl}

View file

@ -1,11 +1,11 @@
import { RouteChildrenProps } from 'react-router-dom';
import { useParams, useLocation, useNavigate } from 'react-router-dom';
import { useMemo } from 'react';
import { isEmpty, pipe } from 'ramda';
import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
import { orderToString, stringToOrder } from '../../utils/helpers/ordering';
import { TagsFilteringMode } from '../../api/types';
type ServerIdRouteProps = RouteChildrenProps<{ serverId: string }>;
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
export interface ShortUrlListRouteParams {
@ -14,40 +14,50 @@ export interface ShortUrlListRouteParams {
}
interface ShortUrlsQueryCommon {
tags?: string;
search?: string;
startDate?: string;
endDate?: string;
tagsMode?: TagsFilteringMode;
}
interface ShortUrlsQuery extends ShortUrlsQueryCommon {
orderBy?: string;
tags?: string;
}
interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
orderBy?: ShortUrlsOrder;
tags: string[];
}
export const useShortUrlsQuery = (
{ history, location, match }: ServerIdRouteProps,
): [ShortUrlsFiltering, ToFirstPage] => {
export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
const navigate = useNavigate();
const location = useLocation();
const params = useParams<{ serverId: string }>();
const query = useMemo(
pipe(
() => parseQuery<ShortUrlsQuery>(location.search),
({ orderBy, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => !orderBy ? rest : {
...rest,
orderBy: stringToOrder<ShortUrlsOrderableFields>(orderBy),
({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => {
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
const parsedTags = tags?.split(',') ?? [];
return { ...rest, orderBy: parsedOrderBy, tags: parsedTags };
},
),
[ location.search ],
);
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
const { orderBy, ...mergedQuery } = { ...query, ...extra };
const normalizedQuery: ShortUrlsQuery = { ...mergedQuery, orderBy: orderBy && orderToString(orderBy) };
const { orderBy, tags, ...mergedQuery } = { ...query, ...extra };
const normalizedQuery: ShortUrlsQuery = {
...mergedQuery,
orderBy: orderBy && orderToString(orderBy),
tags: tags.length > 0 ? tags.join(',') : undefined,
};
const evolvedQuery = stringifyQuery(normalizedQuery);
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;
history.push(`/server/${match?.params.serverId}/list-short-urls/1${queryString}`);
navigate(`/server/${params.serverId}/list-short-urls/1${queryString}`);
};
return [ query, toFirstPageWithExtra ];

View file

@ -1,4 +1,4 @@
import Bottle, { Decorator } from 'bottlejs';
import Bottle from 'bottlejs';
import ShortUrlsFilteringBar from '../ShortUrlsFilteringBar';
import ShortUrlsList from '../ShortUrlsList';
import ShortUrlsRow from '../helpers/ShortUrlsRow';
@ -16,8 +16,9 @@ import QrCodeModal from '../helpers/QrCodeModal';
import { ShortUrlForm } from '../ShortUrlForm';
import { EditShortUrl } from '../EditShortUrl';
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
import { ExportShortUrlsBtn } from '../helpers/ExportShortUrlsBtn';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'ShortUrlsFilteringBar');
bottle.decorator('ShortUrlsList', connect(
@ -49,9 +50,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader', 'ForServerVersion');
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
// Services
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator');
bottle.decorator('ShortUrlsFilteringBar', withRouter);
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator', 'ExportShortUrlsBtn');
bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter');
bottle.decorator('ExportShortUrlsBtn', connect([ 'selectedServer' ]));
// Actions
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');

View file

@ -64,14 +64,14 @@ const TagCard = (
to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center mb-1"
>
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="mr-2" />Short URLs</span>
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="me-2" />Short URLs</span>
<b>{prettify(tag.shortUrls)}</b>
</Link>
<Link
to={`/server/${serverId}/tag/${tag.tag}/visits`}
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center"
>
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="me-2" />Visits</span>
<b>{prettify(tag.visits)}</b>
</Link>
</CardBody>

View file

@ -14,10 +14,10 @@ interface TagsModeDropdownProps {
export const TagsModeDropdown: FC<TagsModeDropdownProps> = ({ mode, onChange, renderTitle }) => (
<DropdownBtn text={renderTitle?.(mode) ?? `Display mode: ${mode}`}>
<DropdownItem active={mode === 'cards'} onClick={() => onChange('cards')}>
<FontAwesomeIcon icon={cardsIcon} fixedWidth className="mr-1" /> Cards
<FontAwesomeIcon icon={cardsIcon} fixedWidth className="me-1" /> Cards
</DropdownItem>
<DropdownItem active={mode === 'list'} onClick={() => onChange('list')}>
<FontAwesomeIcon icon={listIcon} fixedWidth className="mr-1" /> List
<FontAwesomeIcon icon={listIcon} fixedWidth className="me-1" /> List
</DropdownItem>
</DropdownBtn>
);

View file

@ -1,6 +1,6 @@
import { FC, useEffect, useRef } from 'react';
import { splitEvery } from 'ramda';
import { RouteChildrenProps } from 'react-router';
import { useLocation } from 'react-router-dom';
import { SimpleCard } from '../utils/SimpleCard';
import SimplePaginator from '../common/SimplePaginator';
import { useQueryState } from '../utils/helpers/hooks';
@ -18,10 +18,11 @@ export interface TagsTableProps extends TagsListChildrenProps {
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
{ sortedTags, selectedServer, location, orderByColumn, currentOrder }: TagsTableProps & RouteChildrenProps,
{ sortedTags, selectedServer, orderByColumn, currentOrder }: TagsTableProps,
) => {
const isFirstLoad = useRef(true);
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(location.search);
const { search } = useLocation();
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(search);
const [ page, setPage ] = useQueryState<number>('page', Number(pageFromQuery));
const pages = splitEvery(TAGS_PER_PAGE, sortedTags);
const showPaginator = pages.length > 1;
@ -37,16 +38,16 @@ export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
return (
<SimpleCard key={page} bodyClassName={showPaginator ? 'pb-1' : ''}>
<table className="table table-hover mb-0">
<table className="table table-hover responsive-table mb-0">
<thead className="responsive-table__header">
<tr>
<th className="tags-table__header-cell" onClick={orderByColumn('tag')}>
Tag <TableOrderIcon currentOrder={currentOrder} field="tag" />
</th>
<th className="tags-table__header-cell text-lg-right" onClick={orderByColumn('shortUrls')}>
<th className="tags-table__header-cell text-lg-end" onClick={orderByColumn('shortUrls')}>
Short URLs <TableOrderIcon currentOrder={currentOrder} field="shortUrls" />
</th>
<th className="tags-table__header-cell text-lg-right" onClick={orderByColumn('visits')}>
<th className="tags-table__header-cell text-lg-end" onClick={orderByColumn('visits')}>
Visits <TableOrderIcon currentOrder={currentOrder} field="visits" />
</th>
<th className="tags-table__header-cell" />

View file

@ -31,23 +31,23 @@ export const TagsTableRow = (
<th className="responsive-table__cell" data-th="Tag">
<TagBullet tag={tag.tag} colorGenerator={colorGenerator} /> {tag.tag}
</th>
<td className="responsive-table__cell text-lg-right" data-th="Short URLs">
<td className="responsive-table__cell text-lg-end" data-th="Short URLs">
<Link to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}>
{prettify(tag.shortUrls)}
</Link>
</td>
<td className="responsive-table__cell text-lg-right" data-th="Visits">
<td className="responsive-table__cell text-lg-end" data-th="Visits">
<Link to={`/server/${serverId}/tag/${tag.tag}/visits`}>
{prettify(tag.visits)}
</Link>
</td>
<td className="responsive-table__cell text-lg-right">
<td className="responsive-table__cell text-lg-end">
<DropdownBtnMenu toggle={toggleDropdown} isOpen={isDropdownOpen}>
<DropdownItem onClick={toggleEdit}>
<FontAwesomeIcon icon={editIcon} fixedWidth className="mr-1" /> Edit
<FontAwesomeIcon icon={editIcon} fixedWidth className="me-1" /> Edit
</DropdownItem>
<DropdownItem onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth className="mr-1" /> Delete
<FontAwesomeIcon icon={deleteIcon} fixedWidth className="me-1" /> Delete
</DropdownItem>
</DropdownBtnMenu>
</td>

View file

@ -5,3 +5,7 @@
.edit-tag-modal__color-icon {
color: #fff;
}
.edit-tag-modal__popover.edit-tag-modal__popover {
border-radius: .6rem;
}

View file

@ -1,6 +1,6 @@
import { useState } from 'react';
import { Button, Input, Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
import { ChromePicker } from 'react-color';
import { Button, Input, Modal, ModalBody, ModalFooter, ModalHeader, Popover, InputGroup } from 'reactstrap';
import { HexColorPicker } from 'react-colorful';
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '../../utils/helpers/hooks';
@ -37,17 +37,24 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
<form onSubmit={saveTag}>
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
<ModalBody>
<div className="input-group">
<div className="input-group-prepend" id="colorPickerBtn" onClick={toggleColorPicker}>
<div
className="input-group-text edit-tag-modal__color-picker-toggle"
style={{ backgroundColor: color, borderColor: color }}
>
<FontAwesomeIcon icon={colorIcon} className="edit-tag-modal__color-icon" />
</div>
<InputGroup>
<div
id="colorPickerBtn"
className="input-group-text edit-tag-modal__color-picker-toggle"
style={{ backgroundColor: color, borderColor: color }}
onClick={toggleColorPicker}
>
<FontAwesomeIcon icon={colorIcon} className="edit-tag-modal__color-icon" />
</div>
<Popover isOpen={showColorPicker} toggle={toggleColorPicker} target="colorPickerBtn" placement="right">
<ChromePicker color={color} disableAlpha onChange={({ hex }) => setColor(hex)} />
<Popover
isOpen={showColorPicker}
toggle={toggleColorPicker}
target="colorPickerBtn"
placement="right"
hideArrow
popperClassName="edit-tag-modal__popover"
>
<HexColorPicker color={color} onChange={setColor} />
</Popover>
<Input
value={newTagName}
@ -55,7 +62,7 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
required
onChange={({ target }) => setNewTagName(target.value)}
/>
</div>
</InputGroup>
{error && (
<Result type="error" small className="mt-2">

View file

@ -2,6 +2,10 @@
color: #fff;
}
.tag--light-bg {
color: #222 !important;
}
.tag:not(:last-child) {
margin-right: 3px;
}

View file

@ -1,4 +1,5 @@
import { FC, MouseEventHandler } from 'react';
import classNames from 'classnames';
import ColorGenerator from '../../utils/services/ColorGenerator';
import './Tag.scss';
@ -13,7 +14,7 @@ interface TagProps {
const Tag: FC<TagProps> = ({ text, children, clearable, className = '', colorGenerator, onClick, onClose }) => (
<span
className={`badge tag ${className}`}
className={classNames('badge tag', className, { 'tag--light-bg': colorGenerator.isColorLightForKey(text) })}
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
onClick={onClick}
>

View file

@ -1,5 +1,4 @@
import Bottle, { IContainer } from 'bottlejs';
import { withRouter } from 'react-router-dom';
import TagsSelector from '../helpers/TagsSelector';
import TagCard from '../TagCard';
import DeleteTagConfirmModal from '../helpers/DeleteTagConfirmModal';
@ -30,7 +29,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
bottle.serviceFactory('TagsTable', TagsTable, 'TagsTableRow');
bottle.decorator('TagsTable', withRouter);
bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable');
bottle.decorator('TagsList', connect(

View file

@ -4,7 +4,7 @@
$lightPrimaryColor: #ffffff;
$lightPrimaryColorAlfa: rgba($lightPrimaryColor, .5);
$lightSecondaryColor: $lightColor;
$lightTextColor: #212529;
$lightTextColor: #232323;
$lightBorderColor: rgba(0, 0, 0, .125);
$lightTableBorderColor: $mediumGrey;
$lightActiveColor: $lightGrey;
@ -44,6 +44,7 @@ html:not([data-theme='dark']) {
--input-text-color: #{$lightInputTextColor};
--table-border-color: #{$lightTableBorderColor};
--table-highlight-color: #{$lightTableHighlightColor};
--btn-close-filter: initial;
}
html[data-theme='dark'] {
@ -60,4 +61,5 @@ html[data-theme='dark'] {
--input-text-color: #{$darkInputTextColor};
--table-border-color: #{$darkTableBorderColor};
--table-highlight-color: #{$darkTableHighlightColor};
--btn-close-filter: invert(1);
}

View file

@ -20,15 +20,15 @@ const BooleanControl: FC<BooleanControlWithTypeProps> = (
const { current: id } = useRef(uuid());
const onChecked = (e: ChangeEvent<HTMLInputElement>) => onChange(e.target.checked, e);
const typeClasses = {
'custom-switch': type === 'switch',
'custom-checkbox': type === 'checkbox',
'form-switch': type === 'switch',
'form-checkbox': type === 'checkbox',
};
const style = inline ? { display: 'inline-block' } : {};
return (
<span className={classNames('custom-control', typeClasses, className)} style={style}>
<input type="checkbox" className="custom-control-input" id={id} checked={checked} onChange={onChecked} />
<label className="custom-control-label" htmlFor={id}>{children}</label>
<span className={classNames('form-check', typeClasses, className)} style={style}>
<input type="checkbox" className="form-check-input" id={id} checked={checked} onChange={onChecked} />
<label className="form-check-label" htmlFor={id}>{children}</label>
</span>
);
};

View file

@ -11,6 +11,6 @@ interface CopyToClipboardIconProps {
export const CopyToClipboardIcon: FC<CopyToClipboardIconProps> = ({ text, onCopy }) => (
<CopyToClipboard text={text} onCopy={onCopy}>
<FontAwesomeIcon icon={copyIcon} className="ml-2 copy-to-clipboard-icon" />
<FontAwesomeIcon icon={copyIcon} className="ms-2 copy-to-clipboard-icon" />
</CopyToClipboard>
);

View file

@ -88,8 +88,9 @@
z-index: 2;
&[data-placement^='top'] .react-datepicker__triangle.react-datepicker__triangle {
border-top-color: var(--primary-color);
border-bottom-color: var(--border-color);
&::after {
border-top-color: var(--primary-color);
}
&::before {
border-top-color: var(--border-color);
@ -97,8 +98,9 @@
}
&[data-placement^='bottom'] .react-datepicker__triangle.react-datepicker__triangle {
border-top-color: var(--border-color);
border-bottom-color: var(--secondary-color);
&::after {
border-bottom-color: var(--secondary-color);
}
&::before {
border-bottom-color: var(--border-color);

View file

@ -22,7 +22,7 @@ export const DropdownBtn: FC<DropdownBtnProps> = (
return (
<Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled} className={dropdownClassName}>
<DropdownToggle caret className={toggleClasses} color="primary">{text}</DropdownToggle>
<DropdownMenu className="w-100" right={right} style={style}>{children}</DropdownMenu>
<DropdownMenu className="w-100" end={right} style={style}>{children}</DropdownMenu>
</Dropdown>
);
};

View file

@ -15,6 +15,6 @@ export const DropdownBtnMenu: FC<DropdownBtnMenuProps> = ({ isOpen, toggle, chil
<DropdownToggle size="sm" caret outline className="dropdown-btn-menu__dropdown-toggle">
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle>
<DropdownMenu right={right}>{children}</DropdownMenu>
<DropdownMenu end={right}>{children}</DropdownMenu>
</ButtonDropdown>
);

16
src/utils/ExportBtn.tsx Normal file
View file

@ -0,0 +1,16 @@
import { FC } from 'react';
import { Button, ButtonProps } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileDownload } from '@fortawesome/free-solid-svg-icons';
import { prettify } from './helpers/numbers';
interface ExportBtnProps extends Omit<ButtonProps, 'outline' | 'color' | 'disabled'> {
amount?: number;
loading?: boolean;
}
export const ExportBtn: FC<ExportBtnProps> = ({ amount = 0, loading = false, ...rest }) => (
<Button {...rest} outline color="primary" disabled={loading}>
<FontAwesomeIcon icon={faFileDownload} /> {loading ? 'Exporting...' : <>Export ({prettify(amount)})</>}
</Button>
);

View file

@ -1,38 +0,0 @@
import { FC, useRef } from 'react';
import { v4 as uuid } from 'uuid';
import { InputType } from 'reactstrap/lib/Input';
import { FormGroup } from 'reactstrap';
export interface FormGroupContainerProps {
value: string;
onChange: (newValue: string) => void;
id?: string;
type?: InputType;
required?: boolean;
placeholder?: string;
className?: string;
labelClassName?: string;
}
export const FormGroupContainer: FC<FormGroupContainerProps> = (
{ children, value, onChange, id, type, required, placeholder, className, labelClassName },
) => {
const forId = useRef<string>(id ?? uuid());
return (
<FormGroup className={className ?? ''}>
<label htmlFor={forId.current} className={labelClassName ?? ''}>
{children}:
</label>
<input
className="form-control"
type={type ?? 'text'}
id={forId.current}
value={value}
required={required ?? true}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
</FormGroup>
);
};

View file

@ -1,12 +1,12 @@
import { FC, useRef } from 'react';
import * as Popper from 'popper.js';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { UncontrolledTooltip } from 'reactstrap';
import { Placement } from '@popperjs/core';
interface InfoTooltipProps {
className?: string;
placement: Popper.Placement;
placement: Placement;
}
export const InfoTooltip: FC<InfoTooltipProps> = ({ className = '', placement, children }) => {

View file

@ -37,12 +37,12 @@ const Message: FC<MessageProps> = ({ className, children, loading = false, type
});
return (
<Row noGutters className={className}>
<Row className={classNames('g-0', className)}>
<div className={classes}>
<Card className={getClassForType(type)} body>
<h3 className={classNames('text-center mb-0', getTextClassForType(type))}>
{loading && <FontAwesomeIcon icon={preloader} spin />}
{loading && <span className="ml-2">{children ?? 'Loading...'}</span>}
{loading && <span className="ms-2">{children ?? 'Loading...'}</span>}
{!loading && children}
</h3>
</Card>

View file

@ -1,30 +1,31 @@
@import '../utils/base';
@import './base';
.visits-stats__nav {
.nav-pills__nav {
position: sticky !important;
top: $headerHeight - 1px;
z-index: 2;
}
.visits-stats__nav-link {
.nav-pills__nav-link.nav-pills__nav-link {
border-radius: 0 !important;
padding-bottom: calc(.5rem - 3px) !important;
border-bottom: 3px solid transparent;
border-bottom: 3px solid transparent !important;
color: #5d6778;
font-weight: 700;
cursor: pointer;
text-decoration: none;
@media (min-width: $smMin) and (max-width: $mdMax) {
font-size: 89%;
}
}
.visits-stats__nav-link:hover {
.nav-pills__nav-link:hover {
color: $mainColor !important;
}
.visits-stats__nav-link.active {
border-color: $mainColor;
.nav-pills__nav-link.active {
border-color: $mainColor !important;
background-color: var(--primary-color) !important;
color: $mainColor !important;
}

34
src/utils/NavPills.tsx Normal file
View file

@ -0,0 +1,34 @@
import { FC, Children, isValidElement } from 'react';
import { Card, Nav, NavLink } from 'reactstrap';
import { NavLink as RouterNavLink } from 'react-router-dom';
import './NavPills.scss';
interface NavPillsProps {
fill?: boolean;
className?: string;
}
interface NavPillProps {
to: string;
replace?: boolean;
}
export const NavPillItem: FC<NavPillProps> = ({ children, ...rest }) => (
<NavLink className="nav-pills__nav-link" tag={RouterNavLink} {...rest}>
{children}
</NavLink>
);
export const NavPills: FC<NavPillsProps> = ({ children, fill = false, className = '' }) => (
<Card className={`nav-pills__nav p-0 overflow-hidden ${className}`} body>
<Nav pills fill={fill}>
{Children.map(children, (child) => {
if (!isValidElement(child) || child.type !== NavPillItem) {
throw new Error('Only NavPillItem children are allowed inside NavPills.');
}
return child;
})}
</Nav>
</Card>
);

View file

@ -35,7 +35,7 @@ export function OrderingDropdown<T extends string = string>(
{isButton && order.field && `Order by: "${items[order.field]}" - "${order.dir ?? 'DESC'}"`}
</DropdownToggle>
<DropdownMenu
right={right}
end={right}
className={classNames('w-100', { 'ordering-dropdown__menu--link': !isButton })}
>
{toPairs(items).map(([ fieldKey, fieldValue ]) => (

Some files were not shown because too many files have changed in this diff Show more