mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-03 23:07:26 +03:00
Merge pull request #853 from acelaya-forks/feature/shlink-web-component
Split reusable content into separated components
This commit is contained in:
commit
a6134c6b42
429 changed files with 2933 additions and 2593 deletions
|
@ -7,8 +7,8 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "npm run lint:css && npm run lint:js",
|
"lint": "npm run lint:css && npm run lint:js",
|
||||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
"lint:css": "stylelint src/*.scss src/**/*.scss shlink-web-component/*.scss shlink-web-component/**/*.scss shlink-frontend-kit/*.scss shlink-frontend-kit/**/*.scss",
|
||||||
"lint:js": "eslint --ext .js,.ts,.tsx src test",
|
"lint:js": "eslint --ext .js,.ts,.tsx src shlink-web-component shlink-frontend-kit test",
|
||||||
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
||||||
"lint:css:fix": "npm run lint:css -- --fix",
|
"lint:css:fix": "npm run lint:css -- --fix",
|
||||||
"lint:js:fix": "npm run lint:js -- --fix",
|
"lint:js:fix": "npm run lint:js -- --fix",
|
||||||
|
|
|
@ -2,10 +2,10 @@ import type { ReactNode } from 'react';
|
||||||
import type { CardProps } from 'reactstrap';
|
import type { CardProps } from 'reactstrap';
|
||||||
import { Card, CardBody, CardHeader } from 'reactstrap';
|
import { Card, CardBody, CardHeader } from 'reactstrap';
|
||||||
|
|
||||||
interface SimpleCardProps extends Omit<CardProps, 'title'> {
|
export type SimpleCardProps = Omit<CardProps, 'title'> & {
|
||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
bodyClassName?: string;
|
bodyClassName?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const SimpleCard = ({ title, children, bodyClassName, ...rest }: SimpleCardProps) => (
|
export const SimpleCard = ({ title, children, bodyClassName, ...rest }: SimpleCardProps) => (
|
||||||
<Card {...rest}>
|
<Card {...rest}>
|
3
shlink-frontend-kit/src/block/index.ts
Normal file
3
shlink-frontend-kit/src/block/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './Message';
|
||||||
|
export * from './Result';
|
||||||
|
export * from './SimpleCard';
|
|
@ -1,7 +1,7 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { identity } from 'ramda';
|
import { identity } from 'ramda';
|
||||||
import type { ChangeEvent, FC, PropsWithChildren } from 'react';
|
import type { ChangeEvent, FC, PropsWithChildren } from 'react';
|
||||||
import { useDomId } from './helpers/hooks';
|
import { useDomId } from '../hooks';
|
||||||
|
|
||||||
export type BooleanControlProps = PropsWithChildren<{
|
export type BooleanControlProps = PropsWithChildren<{
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
|
@ -10,9 +10,9 @@ export type BooleanControlProps = PropsWithChildren<{
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
interface BooleanControlWithTypeProps extends BooleanControlProps {
|
type BooleanControlWithTypeProps = BooleanControlProps & {
|
||||||
type: 'switch' | 'checkbox';
|
type: 'switch' | 'checkbox';
|
||||||
}
|
};
|
||||||
|
|
||||||
export const BooleanControl: FC<BooleanControlWithTypeProps> = (
|
export const BooleanControl: FC<BooleanControlWithTypeProps> = (
|
||||||
{ checked = false, onChange = identity, className, children, type, inline = false },
|
{ checked = false, onChange = identity, className, children, type, inline = false },
|
|
@ -1,6 +1,6 @@
|
||||||
import type { FC, PropsWithChildren } from 'react';
|
import type { FC, PropsWithChildren } from 'react';
|
||||||
import type { InputType } from 'reactstrap/types/lib/Input';
|
import type { InputType } from 'reactstrap/types/lib/Input';
|
||||||
import { useDomId } from '../helpers/hooks';
|
import { useDomId } from '../hooks';
|
||||||
import { LabeledFormGroup } from './LabeledFormGroup';
|
import { LabeledFormGroup } from './LabeledFormGroup';
|
||||||
|
|
||||||
export type InputFormGroupProps = PropsWithChildren<{
|
export type InputFormGroupProps = PropsWithChildren<{
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../utils/mixins/vertical-align';
|
@import '../../../shlink-web-component/src/utils/mixins/vertical-align';
|
||||||
|
|
||||||
.search-field {
|
.search-field {
|
||||||
position: relative;
|
position: relative;
|
|
@ -7,13 +7,13 @@ import './SearchField.scss';
|
||||||
const DEFAULT_SEARCH_INTERVAL = 500;
|
const DEFAULT_SEARCH_INTERVAL = 500;
|
||||||
let timer: NodeJS.Timeout | null;
|
let timer: NodeJS.Timeout | null;
|
||||||
|
|
||||||
interface SearchFieldProps {
|
type SearchFieldProps = {
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
large?: boolean;
|
large?: boolean;
|
||||||
noBorder?: boolean;
|
noBorder?: boolean;
|
||||||
initialValue?: string;
|
initialValue?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const SearchField = ({ onChange, className, large = true, noBorder = false, initialValue = '' }: SearchFieldProps) => {
|
export const SearchField = ({ onChange, className, large = true, noBorder = false, initialValue = '' }: SearchFieldProps) => {
|
||||||
const [searchTerm, setSearchTerm] = useState(initialValue);
|
const [searchTerm, setSearchTerm] = useState(initialValue);
|
5
shlink-frontend-kit/src/form/index.ts
Normal file
5
shlink-frontend-kit/src/form/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export * from './Checkbox';
|
||||||
|
export * from './ToggleSwitch';
|
||||||
|
export * from './InputFormGroup';
|
||||||
|
export * from './LabeledFormGroup';
|
||||||
|
export * from './SearchField';
|
16
shlink-frontend-kit/src/hooks/index.ts
Normal file
16
shlink-frontend-kit/src/hooks/index.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
type ToggleResult = [boolean, () => void, () => void, () => void];
|
||||||
|
|
||||||
|
export const useToggle = (initialValue = false): ToggleResult => {
|
||||||
|
const [flag, setFlag] = useState<boolean>(initialValue);
|
||||||
|
return [flag, () => setFlag(!flag), () => setFlag(true), () => setFlag(false)];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDomId = (): string => {
|
||||||
|
const { current: id } = useRef(`dom-${uuid()}`);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useElementRef = <T>() => useRef<T | null>(null);
|
219
shlink-frontend-kit/src/index.scss
Normal file
219
shlink-frontend-kit/src/index.scss
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
@import './utils/ResponsiveTable';
|
||||||
|
@import './theme/theme';
|
||||||
|
|
||||||
|
/* stylelint-disable no-descending-specificity */
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-warning {
|
||||||
|
color: $lightTextColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body,
|
||||||
|
.card-header,
|
||||||
|
.list-group-item {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
background-color: var(--primary-color-alfa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
box-shadow: 0 .125rem .25rem rgb(0 0 0 / .075);
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content,
|
||||||
|
.page-link,
|
||||||
|
.page-item.disabled .page-link,
|
||||||
|
.dropdown-menu {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header,
|
||||||
|
.modal-footer,
|
||||||
|
.card-header,
|
||||||
|
.card-footer,
|
||||||
|
.table thead th,
|
||||||
|
.table th,
|
||||||
|
.table td,
|
||||||
|
.page-link,
|
||||||
|
.page-link:hover,
|
||||||
|
.page-item.disabled .page-link,
|
||||||
|
.dropdown-divider,
|
||||||
|
.dropdown-menu,
|
||||||
|
.list-group-item,
|
||||||
|
.modal-content,
|
||||||
|
hr {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-bordered,
|
||||||
|
.table-bordered thead th,
|
||||||
|
.table-bordered thead td {
|
||||||
|
border-color: var(--table-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-link:hover,
|
||||||
|
.page-link:focus {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item.active .page-link {
|
||||||
|
background-color: var(--brand-color);
|
||||||
|
border-color: var(--brand-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-link {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-xl {
|
||||||
|
@media (min-width: $xlgMin) {
|
||||||
|
max-width: 1320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $smMax) {
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:focus:not(:disabled),
|
||||||
|
.dropdown-item:hover:not(:disabled),
|
||||||
|
.dropdown-item.active:not(:disabled),
|
||||||
|
.dropdown-item:active:not(:disabled) {
|
||||||
|
background-color: var(--active-color) !important;
|
||||||
|
color: var(--text-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item--danger.dropdown-item--danger {
|
||||||
|
color: $dangerColor;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
color: $dangerColor !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-main {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--brand-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close,
|
||||||
|
.close:hover,
|
||||||
|
.table,
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-control:focus {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--input-border-color);
|
||||||
|
color: var(--input-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control.disabled,
|
||||||
|
.form-control:disabled {
|
||||||
|
background-color: var(--input-disabled-color);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .form-control:not(:disabled),
|
||||||
|
.card .form-control:not(:disabled):hover {
|
||||||
|
background-color: var(--input-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-active,
|
||||||
|
.table-active > th,
|
||||||
|
.table-active > td {
|
||||||
|
background-color: var(--table-highlight-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
@media (max-width: $smMax) {
|
||||||
|
margin: 0 auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.indivisible {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
background-color: $mainColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-xs-block {
|
||||||
|
@media (max-width: $xsMax) {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-md-block {
|
||||||
|
@media (max-width: $mdMax) {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
7
shlink-frontend-kit/src/index.ts
Normal file
7
shlink-frontend-kit/src/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export * from './block';
|
||||||
|
export * from './form';
|
||||||
|
export * from './hooks';
|
||||||
|
export * from './navigation';
|
||||||
|
export * from './ordering';
|
||||||
|
export * from './theme';
|
||||||
|
export * from './utils';
|
|
@ -1,6 +1,6 @@
|
||||||
/* stylelint-disable no-descending-specificity */
|
/* stylelint-disable no-descending-specificity */
|
||||||
|
|
||||||
@import '../utils/mixins/vertical-align';
|
@import '../../../shlink-web-component/src/utils/mixins/vertical-align';
|
||||||
|
|
||||||
.dropdown-btn__toggle.dropdown-btn__toggle {
|
.dropdown-btn__toggle.dropdown-btn__toggle {
|
||||||
text-align: left;
|
text-align: left;
|
|
@ -2,7 +2,7 @@ import classNames from 'classnames';
|
||||||
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
||||||
import { Dropdown, DropdownMenu, DropdownToggle } from 'reactstrap';
|
import { Dropdown, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||||
import type { DropdownToggleProps } from 'reactstrap/types/lib/DropdownToggle';
|
import type { DropdownToggleProps } from 'reactstrap/types/lib/DropdownToggle';
|
||||||
import { useToggle } from './helpers/hooks';
|
import { useToggle } from '../hooks';
|
||||||
import './DropdownBtn.scss';
|
import './DropdownBtn.scss';
|
||||||
|
|
||||||
export type DropdownBtnProps = PropsWithChildren<Omit<DropdownToggleProps, 'caret' | 'size' | 'outline'> & {
|
export type DropdownBtnProps = PropsWithChildren<Omit<DropdownToggleProps, 'caret' | 'size' | 'outline'> & {
|
|
@ -1,4 +1,4 @@
|
||||||
@import './base';
|
@import '../base';
|
||||||
|
|
||||||
.nav-pills__nav {
|
.nav-pills__nav {
|
||||||
position: sticky !important;
|
position: sticky !important;
|
3
shlink-frontend-kit/src/navigation/index.ts
Normal file
3
shlink-frontend-kit/src/navigation/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './DropdownBtn';
|
||||||
|
export * from './RowDropdownBtn';
|
||||||
|
export * from './NavPills';
|
|
@ -3,18 +3,18 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { toPairs } from 'ramda';
|
import { toPairs } from 'ramda';
|
||||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||||
import type { Order, OrderDir } from './helpers/ordering';
|
import type { Order, OrderDir } from './ordering';
|
||||||
import { determineOrderDir } from './helpers/ordering';
|
import { determineOrderDir } from './ordering';
|
||||||
import './OrderingDropdown.scss';
|
import './OrderingDropdown.scss';
|
||||||
|
|
||||||
export interface OrderingDropdownProps<T extends string = string> {
|
export type OrderingDropdownProps<T extends string = string> = {
|
||||||
items: Record<T, string>;
|
items: Record<T, string>;
|
||||||
order: Order<T>;
|
order: Order<T>;
|
||||||
onChange: (orderField?: T, orderDir?: OrderDir) => void;
|
onChange: (orderField?: T, orderDir?: OrderDir) => void;
|
||||||
isButton?: boolean;
|
isButton?: boolean;
|
||||||
right?: boolean;
|
right?: boolean;
|
||||||
prefixed?: boolean;
|
prefixed?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function OrderingDropdown<T extends string = string>(
|
export function OrderingDropdown<T extends string = string>(
|
||||||
{ items, order, onChange, isButton = true, right = false, prefixed = true }: OrderingDropdownProps<T>,
|
{ items, order, onChange, isButton = true, right = false, prefixed = true }: OrderingDropdownProps<T>,
|
2
shlink-frontend-kit/src/ordering/index.ts
Normal file
2
shlink-frontend-kit/src/ordering/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './ordering';
|
||||||
|
export * from './OrderingDropdown';
|
|
@ -1,9 +1,9 @@
|
||||||
export type OrderDir = 'ASC' | 'DESC' | undefined;
|
export type OrderDir = 'ASC' | 'DESC' | undefined;
|
||||||
|
|
||||||
export interface Order<Fields> {
|
export type Order<Fields> = {
|
||||||
field?: Fields;
|
field?: Fields;
|
||||||
dir?: OrderDir;
|
dir?: OrderDir;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const determineOrderDir = <T extends string = string>(
|
export const determineOrderDir = <T extends string = string>(
|
||||||
currentField: T,
|
currentField: T,
|
||||||
|
@ -22,7 +22,7 @@ export const determineOrderDir = <T extends string = string>(
|
||||||
return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC';
|
return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sortList = <List>(list: List[], { field, dir }: Order<Partial<keyof List>>) => (
|
export const sortList = <List>(list: List[], { field, dir }: Order<keyof List>) => (
|
||||||
!field || !dir ? list : list.sort((a, b) => {
|
!field || !dir ? list : list.sort((a, b) => {
|
||||||
const greaterThan = dir === 'ASC' ? 1 : -1;
|
const greaterThan = dir === 'ASC' ? 1 : -1;
|
||||||
const smallerThan = dir === 'ASC' ? -1 : 1;
|
const smallerThan = dir === 'ASC' ? -1 : 1;
|
|
@ -12,8 +12,6 @@ export const PRIMARY_DARK_COLOR = '#161b22';
|
||||||
|
|
||||||
export type Theme = 'dark' | 'light';
|
export type Theme = 'dark' | 'light';
|
||||||
|
|
||||||
export const changeThemeInMarkup = (theme: Theme) =>
|
export const changeThemeInMarkup = (theme: Theme) => document.querySelector('html')?.setAttribute('data-theme', theme);
|
||||||
document.getElementsByTagName('html')?.[0]?.setAttribute('data-theme', theme);
|
|
||||||
|
|
||||||
export const isDarkThemeEnabled = (): boolean =>
|
export const isDarkThemeEnabled = (): boolean => document.querySelector('html')?.getAttribute('data-theme') === 'dark';
|
||||||
document.getElementsByTagName('html')?.[0]?.getAttribute('data-theme') === 'dark';
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../../utils/base';
|
@import '../base';
|
||||||
|
|
||||||
.responsive-table__header {
|
.responsive-table__header {
|
||||||
@media (max-width: $responsiveTableBreakpoint) {
|
@media (max-width: $responsiveTableBreakpoint) {
|
|
@ -1,5 +1,7 @@
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
|
|
||||||
|
// FIXME Use URLSearchParams instead of qs package
|
||||||
|
|
||||||
export const parseQuery = <T>(search: string) => qs.parse(search, { ignoreQueryPrefix: true }) as unknown as T;
|
export const parseQuery = <T>(search: string) => qs.parse(search, { ignoreQueryPrefix: true }) as unknown as T;
|
||||||
|
|
||||||
export const stringifyQuery = (query: any): string => qs.stringify(query, { arrayFormat: 'brackets' });
|
export const stringifyQuery = (query: any): string => qs.stringify(query, { arrayFormat: 'brackets' });
|
8
shlink-frontend-kit/test/__helpers__/setUpTest.ts
Normal file
8
shlink-frontend-kit/test/__helpers__/setUpTest.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
|
||||||
|
export const renderWithEvents = (element: ReactElement) => ({
|
||||||
|
user: userEvent.setup(),
|
||||||
|
...render(element),
|
||||||
|
});
|
|
@ -1,7 +1,7 @@
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import type { PropsWithChildren } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
import type { MessageProps } from '../../src/utils/Message';
|
import type { MessageProps } from '../../src';
|
||||||
import { Message } from '../../src/utils/Message';
|
import { Message } from '../../src';
|
||||||
|
|
||||||
describe('<Message />', () => {
|
describe('<Message />', () => {
|
||||||
const setUp = (props: PropsWithChildren<MessageProps> = {}) => render(<Message {...props} />);
|
const setUp = (props: PropsWithChildren<MessageProps> = {}) => render(<Message {...props} />);
|
|
@ -1,6 +1,6 @@
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import type { ResultProps, ResultType } from '../../src/utils/Result';
|
import type { ResultProps, ResultType } from '../../src';
|
||||||
import { Result } from '../../src/utils/Result';
|
import { Result } from '../../src';
|
||||||
|
|
||||||
describe('<Result />', () => {
|
describe('<Result />', () => {
|
||||||
const setUp = (props: ResultProps) => render(<Result {...props} />);
|
const setUp = (props: ResultProps) => render(<Result {...props} />);
|
|
@ -1,24 +1,27 @@
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { SimpleCard } from '../../src/utils/SimpleCard';
|
import type { SimpleCardProps } from '../../src';
|
||||||
|
import { SimpleCard } from '../../src';
|
||||||
|
|
||||||
|
const setUp = ({ children, ...rest }: SimpleCardProps = {}) => render(<SimpleCard {...rest}>{children}</SimpleCard>);
|
||||||
|
|
||||||
describe('<SimpleCard />', () => {
|
describe('<SimpleCard />', () => {
|
||||||
it('does not render title if not provided', () => {
|
it('does not render title if not provided', () => {
|
||||||
render(<SimpleCard />);
|
setUp();
|
||||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders provided title', () => {
|
it('renders provided title', () => {
|
||||||
render(<SimpleCard title="Cool title" />);
|
setUp({ title: 'Cool title' });
|
||||||
expect(screen.getByRole('heading')).toHaveTextContent('Cool title');
|
expect(screen.getByRole('heading')).toHaveTextContent('Cool title');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders children inside body', () => {
|
it('renders children inside body', () => {
|
||||||
render(<SimpleCard>Hello world</SimpleCard>);
|
setUp({ children: 'Hello world' });
|
||||||
expect(screen.getByText('Hello world')).toBeInTheDocument();
|
expect(screen.getByText('Hello world')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['primary', 'danger', 'warning'])('passes extra props to nested card', (color) => {
|
it.each(['primary', 'danger', 'warning'])('passes extra props to nested card', (color) => {
|
||||||
const { container } = render(<SimpleCard className="foo" color={color}>Hello world</SimpleCard>);
|
const { container } = setUp({ className: 'foo', color, children: 'Hello world' });
|
||||||
expect(container.firstChild).toHaveAttribute('class', `foo card bg-${color}`);
|
expect(container.firstChild).toHaveAttribute('class', `foo card bg-${color}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
|
@ -1,5 +1,5 @@
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { Checkbox } from '../../src/utils/Checkbox';
|
import { Checkbox } from '../../src';
|
||||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<Checkbox />', () => {
|
describe('<Checkbox />', () => {
|
|
@ -1,7 +1,7 @@
|
||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import type { PropsWithChildren } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
import type { DropdownBtnProps } from '../../src/utils/DropdownBtn';
|
import type { DropdownBtnProps } from '../../src';
|
||||||
import { DropdownBtn } from '../../src/utils/DropdownBtn';
|
import { DropdownBtn } from '../../src';
|
||||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<DropdownBtn />', () => {
|
describe('<DropdownBtn />', () => {
|
|
@ -1,7 +1,7 @@
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { NavPillItem, NavPills } from '../../src/utils/NavPills';
|
import { NavPillItem, NavPills } from '../../src';
|
||||||
|
|
||||||
describe('<NavPills />', () => {
|
describe('<NavPills />', () => {
|
||||||
let originalError: typeof console.error;
|
let originalError: typeof console.error;
|
|
@ -1,7 +1,7 @@
|
||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import type { DropdownBtnMenuProps } from '../../src/utils/RowDropdownBtn';
|
import type { DropdownBtnMenuProps } from '../../src';
|
||||||
import { RowDropdownBtn } from '../../src/utils/RowDropdownBtn';
|
import { RowDropdownBtn } from '../../src';
|
||||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<RowDropdownBtn />', () => {
|
describe('<RowDropdownBtn />', () => {
|
|
@ -1,8 +1,7 @@
|
||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { values } from 'ramda';
|
import { values } from 'ramda';
|
||||||
import type { OrderDir } from '../../src/utils/helpers/ordering';
|
import type { OrderDir, OrderingDropdownProps } from '../../src';
|
||||||
import type { OrderingDropdownProps } from '../../src/utils/OrderingDropdown';
|
import { OrderingDropdown } from '../../src';
|
||||||
import { OrderingDropdown } from '../../src/utils/OrderingDropdown';
|
|
||||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<OrderingDropdown />', () => {
|
describe('<OrderingDropdown />', () => {
|
|
@ -1,5 +1,5 @@
|
||||||
import type { OrderDir } from '../../../src/utils/helpers/ordering';
|
import type { OrderDir } from '../../src';
|
||||||
import { determineOrderDir, orderToString, stringToOrder } from '../../../src/utils/helpers/ordering';
|
import { determineOrderDir, orderToString, stringToOrder } from '../../src';
|
||||||
|
|
||||||
describe('ordering', () => {
|
describe('ordering', () => {
|
||||||
describe('determineOrderDir', () => {
|
describe('determineOrderDir', () => {
|
|
@ -1,4 +1,4 @@
|
||||||
import { parseQuery, stringifyQuery } from '../../../src/utils/helpers/query';
|
import { parseQuery, stringifyQuery } from '../../src/utils';
|
||||||
|
|
||||||
describe('query', () => {
|
describe('query', () => {
|
||||||
describe('parseQuery', () => {
|
describe('parseQuery', () => {
|
|
@ -1,14 +1,14 @@
|
||||||
@import '../utils/base';
|
@import '@shlinkio/shlink-frontend-kit/base';
|
||||||
|
|
||||||
.menu-layout__swipeable {
|
.shlink-layout__swipeable {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-layout__swipeable-inner {
|
.shlink-layout__swipeable-inner {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-layout__burger-icon {
|
.shlink-layout__burger-icon {
|
||||||
display: none;
|
display: none;
|
||||||
transition: color 300ms;
|
transition: color 300ms;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
@ -23,11 +23,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-layout__burger-icon--active {
|
.shlink-layout__burger-icon--active {
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-layout__container.menu-layout__container {
|
.shlink-layout__container.shlink-layout__container {
|
||||||
padding: 20px 0 0;
|
padding: 20px 0 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
|
79
shlink-web-component/src/Main.tsx
Normal file
79
shlink-web-component/src/Main.tsx
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { Fragment, useEffect, useMemo } from 'react';
|
||||||
|
import { BrowserRouter, Navigate, Route, Routes, useInRouterContext, useLocation } from 'react-router-dom';
|
||||||
|
import { AsideMenu } from './common/AsideMenu';
|
||||||
|
import { useFeature } from './utils/features';
|
||||||
|
import { useSwipeable } from './utils/helpers/hooks';
|
||||||
|
import { useRoutesPrefix } from './utils/routesPrefix';
|
||||||
|
import './Main.scss';
|
||||||
|
|
||||||
|
export type MainProps = {
|
||||||
|
createNotFound?: (nonPrefixedHomePath: string) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Main = (
|
||||||
|
TagsList: FC,
|
||||||
|
ShortUrlsList: FC,
|
||||||
|
CreateShortUrl: FC,
|
||||||
|
ShortUrlVisits: FC,
|
||||||
|
TagVisits: FC,
|
||||||
|
DomainVisits: FC,
|
||||||
|
OrphanVisits: FC,
|
||||||
|
NonOrphanVisits: FC,
|
||||||
|
Overview: FC,
|
||||||
|
EditShortUrl: FC,
|
||||||
|
ManageDomains: FC,
|
||||||
|
): FC<MainProps> => ({ createNotFound }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const routesPrefix = useRoutesPrefix();
|
||||||
|
const inRouterContext = useInRouterContext();
|
||||||
|
const [Wrapper, props] = useMemo(() => (
|
||||||
|
inRouterContext
|
||||||
|
? [Fragment, {}]
|
||||||
|
: [BrowserRouter, { basename: routesPrefix }]
|
||||||
|
), [inRouterContext]);
|
||||||
|
|
||||||
|
const [sidebarVisible, toggleSidebar, showSidebar, hideSidebar] = useToggle();
|
||||||
|
useEffect(() => hideSidebar(), [location]);
|
||||||
|
|
||||||
|
const addDomainVisitsRoute = useFeature('domainVisits');
|
||||||
|
const burgerClasses = classNames('shlink-layout__burger-icon', { 'shlink-layout__burger-icon--active': sidebarVisible });
|
||||||
|
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
||||||
|
|
||||||
|
// FIXME Check if this works when not currently wrapped in a router
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper {...props}>
|
||||||
|
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
||||||
|
|
||||||
|
<div {...swipeableProps} className="shlink-layout__swipeable">
|
||||||
|
<div className="shlink-layout__swipeable-inner">
|
||||||
|
<AsideMenu routePrefix={routesPrefix} showOnMobile={sidebarVisible} />
|
||||||
|
<div className="shlink-layout__container" onClick={() => hideSidebar()}>
|
||||||
|
<div className="container-xl">
|
||||||
|
<Routes>
|
||||||
|
<Route index element={<Navigate replace to="overview" />} />
|
||||||
|
<Route path="/overview" element={<Overview />} />
|
||||||
|
<Route path="/list-short-urls/:page" element={<ShortUrlsList />} />
|
||||||
|
<Route path="/create-short-url" element={<CreateShortUrl />} />
|
||||||
|
<Route path="/short-code/:shortCode/visits/*" element={<ShortUrlVisits />} />
|
||||||
|
<Route path="/short-code/:shortCode/edit" element={<EditShortUrl />} />
|
||||||
|
<Route path="/tag/:tag/visits/*" element={<TagVisits />} />
|
||||||
|
{addDomainVisitsRoute && <Route path="/domain/:domain/visits/*" element={<DomainVisits />} />}
|
||||||
|
<Route path="/orphan-visits/*" element={<OrphanVisits />} />
|
||||||
|
<Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />
|
||||||
|
<Route path="/manage-tags" element={<TagsList />} />
|
||||||
|
<Route path="/manage-domains" element={<ManageDomains />} />
|
||||||
|
{createNotFound && <Route path="*" element={createNotFound('/list-short-urls/1')} />}
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
};
|
67
shlink-web-component/src/ShlinkWebComponent.tsx
Normal file
67
shlink-web-component/src/ShlinkWebComponent.tsx
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import type { Store } from '@reduxjs/toolkit';
|
||||||
|
import type Bottle from 'bottlejs';
|
||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Provider as ReduxStoreProvider } from 'react-redux';
|
||||||
|
import type { ShlinkApiClient } from './api-contract';
|
||||||
|
import { FeaturesProvider, useFeatures } from './utils/features';
|
||||||
|
import type { SemVer } from './utils/helpers/version';
|
||||||
|
import { RoutesPrefixProvider } from './utils/routesPrefix';
|
||||||
|
import type { TagColorsStorage } from './utils/services/TagColorsStorage';
|
||||||
|
import type { Settings } from './utils/settings';
|
||||||
|
import { SettingsProvider } from './utils/settings';
|
||||||
|
|
||||||
|
type ShlinkWebComponentProps = {
|
||||||
|
serverVersion: SemVer; // FIXME Consider making this optional and trying to resolve it if not set
|
||||||
|
apiClient: ShlinkApiClient;
|
||||||
|
tagColorsStorage?: TagColorsStorage;
|
||||||
|
routesPrefix?: string;
|
||||||
|
settings?: Settings;
|
||||||
|
createNotFound?: (nonPrefixedHomePath: string) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
// FIXME This allows to track the reference to be resolved by the container, but it's hacky and relies on not more than
|
||||||
|
// one ShlinkWebComponent rendered at the same time.
|
||||||
|
// Works for now, but should be addressed.
|
||||||
|
let apiClientRef: ShlinkApiClient;
|
||||||
|
|
||||||
|
export const createShlinkWebComponent = (
|
||||||
|
bottle: Bottle,
|
||||||
|
): FC<ShlinkWebComponentProps> => (
|
||||||
|
{ serverVersion, apiClient, settings, routesPrefix = '', createNotFound, tagColorsStorage },
|
||||||
|
) => {
|
||||||
|
const features = useFeatures(serverVersion);
|
||||||
|
const mainContent = useRef<ReactNode>();
|
||||||
|
const [theStore, setStore] = useState<Store | undefined>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClientRef = apiClient;
|
||||||
|
bottle.value('apiClientFactory', () => apiClientRef);
|
||||||
|
|
||||||
|
if (tagColorsStorage) {
|
||||||
|
bottle.value('TagColorsStorage', tagColorsStorage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// It's important to not try to resolve services before the API client has been registered, as many other services
|
||||||
|
// depend on it
|
||||||
|
const { container } = bottle;
|
||||||
|
const { Main, store, loadMercureInfo } = container;
|
||||||
|
mainContent.current = <Main createNotFound={createNotFound} />;
|
||||||
|
setStore(store);
|
||||||
|
|
||||||
|
// Load mercure info
|
||||||
|
store.dispatch(loadMercureInfo(settings));
|
||||||
|
}, [apiClient, tagColorsStorage]);
|
||||||
|
|
||||||
|
return !theStore ? <></> : (
|
||||||
|
<ReduxStoreProvider store={theStore}>
|
||||||
|
<SettingsProvider value={settings}>
|
||||||
|
<FeaturesProvider value={features}>
|
||||||
|
<RoutesPrefixProvider value={routesPrefix}>
|
||||||
|
{mainContent.current}
|
||||||
|
</RoutesPrefixProvider>
|
||||||
|
</FeaturesProvider>
|
||||||
|
</SettingsProvider>
|
||||||
|
</ReduxStoreProvider>
|
||||||
|
);
|
||||||
|
};
|
63
shlink-web-component/src/api-contract/ShlinkApiClient.ts
Normal file
63
shlink-web-component/src/api-contract/ShlinkApiClient.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import type {
|
||||||
|
ShlinkCreateShortUrlData,
|
||||||
|
ShlinkDomainRedirects,
|
||||||
|
ShlinkDomainsResponse,
|
||||||
|
ShlinkEditDomainRedirects,
|
||||||
|
ShlinkEditShortUrlData,
|
||||||
|
ShlinkHealth,
|
||||||
|
ShlinkMercureInfo,
|
||||||
|
ShlinkShortUrl,
|
||||||
|
ShlinkShortUrlsListParams,
|
||||||
|
ShlinkShortUrlsResponse,
|
||||||
|
ShlinkTags,
|
||||||
|
ShlinkVisits,
|
||||||
|
ShlinkVisitsOverview,
|
||||||
|
ShlinkVisitsParams,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export type ShlinkApiClient = {
|
||||||
|
readonly baseUrl: string;
|
||||||
|
readonly apiKey: string;
|
||||||
|
|
||||||
|
listShortUrls(params?: ShlinkShortUrlsListParams): Promise<ShlinkShortUrlsResponse>;
|
||||||
|
|
||||||
|
createShortUrl(options: ShlinkCreateShortUrlData): Promise<ShlinkShortUrl>;
|
||||||
|
|
||||||
|
getShortUrlVisits(shortCode: string, query?: ShlinkVisitsParams): Promise<ShlinkVisits>;
|
||||||
|
|
||||||
|
getTagVisits(tag: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits>;
|
||||||
|
|
||||||
|
getDomainVisits(domain: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits>;
|
||||||
|
|
||||||
|
getOrphanVisits(query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits>;
|
||||||
|
|
||||||
|
getNonOrphanVisits(query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits>;
|
||||||
|
|
||||||
|
getVisitsOverview(): Promise<ShlinkVisitsOverview>;
|
||||||
|
|
||||||
|
getShortUrl(shortCode: string, domain?: string | null): Promise<ShlinkShortUrl>;
|
||||||
|
|
||||||
|
deleteShortUrl(shortCode: string, domain?: string | null): Promise<void>;
|
||||||
|
|
||||||
|
updateShortUrl(
|
||||||
|
shortCode: string,
|
||||||
|
domain: string | null | undefined,
|
||||||
|
body: ShlinkEditShortUrlData,
|
||||||
|
): Promise<ShlinkShortUrl>;
|
||||||
|
|
||||||
|
listTags(): Promise<ShlinkTags>;
|
||||||
|
|
||||||
|
tagsStats(): Promise<ShlinkTags>;
|
||||||
|
|
||||||
|
deleteTags(tags: string[]): Promise<{ tags: string[] }>;
|
||||||
|
|
||||||
|
editTag(oldName: string, newName: string): Promise<{ oldName: string; newName: string }>;
|
||||||
|
|
||||||
|
health(authority?: string): Promise<ShlinkHealth>;
|
||||||
|
|
||||||
|
mercureInfo(): Promise<ShlinkMercureInfo>;
|
||||||
|
|
||||||
|
listDomains(): Promise<ShlinkDomainsResponse>;
|
||||||
|
|
||||||
|
editDomainRedirects(domainRedirects: ShlinkEditDomainRedirects): Promise<ShlinkDomainRedirects>;
|
||||||
|
};
|
3
shlink-web-component/src/api-contract/index.ts
Normal file
3
shlink-web-component/src/api-contract/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './errors';
|
||||||
|
export * from './ShlinkApiClient';
|
||||||
|
export * from './types';
|
|
@ -1,10 +1,66 @@
|
||||||
import type { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
|
import type { Order } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { Order } from '../../utils/helpers/ordering';
|
import type { Nullable, OptionalString } from '../utils/helpers';
|
||||||
import type { OptionalString } from '../../utils/utils';
|
import type { Visit } from '../visits/types';
|
||||||
import type { Visit } from '../../visits/types';
|
|
||||||
|
export interface ShlinkDeviceLongUrls {
|
||||||
|
android?: OptionalString;
|
||||||
|
ios?: OptionalString;
|
||||||
|
desktop?: OptionalString;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkShortUrlMeta {
|
||||||
|
validSince?: string;
|
||||||
|
validUntil?: string;
|
||||||
|
maxVisits?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkShortUrl {
|
||||||
|
shortCode: string;
|
||||||
|
shortUrl: string;
|
||||||
|
longUrl: string;
|
||||||
|
deviceLongUrls?: Required<ShlinkDeviceLongUrls>, // Optional only before Shlink 3.5.0
|
||||||
|
dateCreated: string;
|
||||||
|
/** @deprecated */
|
||||||
|
visitsCount: number; // Deprecated since Shlink 3.4.0
|
||||||
|
visitsSummary?: ShlinkVisitsSummary; // Optional only before Shlink 3.4.0
|
||||||
|
meta: Required<Nullable<ShlinkShortUrlMeta>>;
|
||||||
|
tags: string[];
|
||||||
|
domain: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
crawlable?: boolean;
|
||||||
|
forwardQuery?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkEditShortUrlData {
|
||||||
|
longUrl?: string;
|
||||||
|
title?: string | null;
|
||||||
|
tags?: string[];
|
||||||
|
deviceLongUrls?: ShlinkDeviceLongUrls;
|
||||||
|
crawlable?: boolean;
|
||||||
|
forwardQuery?: boolean;
|
||||||
|
validSince?: string | null;
|
||||||
|
validUntil?: string | null;
|
||||||
|
maxVisits?: number | null;
|
||||||
|
|
||||||
|
/** @deprecated */
|
||||||
|
validateUrl?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkCreateShortUrlData extends Omit<ShlinkEditShortUrlData, 'deviceLongUrls'> {
|
||||||
|
longUrl: string;
|
||||||
|
customSlug?: string;
|
||||||
|
shortCodeLength?: number;
|
||||||
|
domain?: string;
|
||||||
|
findIfExists?: boolean;
|
||||||
|
deviceLongUrls?: {
|
||||||
|
android?: string;
|
||||||
|
ios?: string;
|
||||||
|
desktop?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShlinkShortUrlsResponse {
|
export interface ShlinkShortUrlsResponse {
|
||||||
data: ShortUrl[];
|
data: ShlinkShortUrl[];
|
||||||
pagination: ShlinkPaginator;
|
pagination: ShlinkPaginator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,7 +126,7 @@ export interface ShlinkVisitsOverview {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkVisitsParams {
|
export interface ShlinkVisitsParams {
|
||||||
domain?: OptionalString;
|
domain?: string | null;
|
||||||
page?: number;
|
page?: number;
|
||||||
itemsPerPage?: number;
|
itemsPerPage?: number;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
|
@ -78,13 +134,6 @@ export interface ShlinkVisitsParams {
|
||||||
excludeBots?: boolean;
|
excludeBots?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkShortUrlData extends ShortUrlMeta {
|
|
||||||
longUrl?: string;
|
|
||||||
title?: string;
|
|
||||||
validateUrl?: boolean;
|
|
||||||
tags?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShlinkDomainRedirects {
|
export interface ShlinkDomainRedirects {
|
||||||
baseUrlRedirect: string | null;
|
baseUrlRedirect: string | null;
|
||||||
regular404Redirect: string | null;
|
regular404Redirect: string | null;
|
||||||
|
@ -98,12 +147,12 @@ export interface ShlinkEditDomainRedirects extends Partial<ShlinkDomainRedirects
|
||||||
export interface ShlinkDomain {
|
export interface ShlinkDomain {
|
||||||
domain: string;
|
domain: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
redirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.8
|
redirects: ShlinkDomainRedirects;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkDomainsResponse {
|
export interface ShlinkDomainsResponse {
|
||||||
data: ShlinkDomain[];
|
data: ShlinkDomain[];
|
||||||
defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10
|
defaultRedirects: ShlinkDomainRedirects;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TagsFilteringMode = 'all' | 'any';
|
export type TagsFilteringMode = 'all' | 'any';
|
|
@ -2,16 +2,11 @@ import type {
|
||||||
InvalidArgumentError,
|
InvalidArgumentError,
|
||||||
InvalidShortUrlDeletion,
|
InvalidShortUrlDeletion,
|
||||||
ProblemDetailsError,
|
ProblemDetailsError,
|
||||||
RegularNotFound } from '../types/errors';
|
RegularNotFound } from './errors';
|
||||||
import {
|
import {
|
||||||
ErrorTypeV2,
|
ErrorTypeV2,
|
||||||
ErrorTypeV3,
|
ErrorTypeV3,
|
||||||
} from '../types/errors';
|
} from './errors';
|
||||||
|
|
||||||
const isProblemDetails = (e: unknown): e is ProblemDetailsError =>
|
|
||||||
!!e && typeof e === 'object' && ['type', 'detail', 'title', 'status'].every((prop) => prop in e);
|
|
||||||
|
|
||||||
export const parseApiError = (e: unknown): ProblemDetailsError | undefined => (isProblemDetails(e) ? e : undefined);
|
|
||||||
|
|
||||||
export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError =>
|
export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError =>
|
||||||
error?.type === ErrorTypeV2.INVALID_ARGUMENT || error?.type === ErrorTypeV3.INVALID_ARGUMENT;
|
error?.type === ErrorTypeV2.INVALID_ARGUMENT || error?.type === ErrorTypeV3.INVALID_ARGUMENT;
|
||||||
|
@ -23,3 +18,8 @@ export const isInvalidDeletionError = (error?: ProblemDetailsError): error is In
|
||||||
|
|
||||||
export const isRegularNotFound = (error?: ProblemDetailsError): error is RegularNotFound =>
|
export const isRegularNotFound = (error?: ProblemDetailsError): error is RegularNotFound =>
|
||||||
(error?.type === ErrorTypeV2.NOT_FOUND || error?.type === ErrorTypeV3.NOT_FOUND) && error?.status === 404;
|
(error?.type === ErrorTypeV2.NOT_FOUND || error?.type === ErrorTypeV3.NOT_FOUND) && error?.status === 404;
|
||||||
|
|
||||||
|
const isProblemDetails = (e: unknown): e is ProblemDetailsError =>
|
||||||
|
!!e && typeof e === 'object' && ['type', 'detail', 'title', 'status'].every((prop) => prop in e);
|
||||||
|
|
||||||
|
export const parseApiError = (e: unknown): ProblemDetailsError | undefined => (isProblemDetails(e) ? e : undefined);
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../utils/base';
|
@import '@shlinkio/shlink-frontend-kit/base';
|
||||||
@import '../utils/mixins/vertical-align';
|
@import '../utils/mixins/vertical-align';
|
||||||
|
|
||||||
.aside-menu {
|
.aside-menu {
|
||||||
|
@ -58,24 +58,6 @@
|
||||||
background-color: var(--brand-color);
|
background-color: var(--brand-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside-menu__item--divider {
|
|
||||||
border-bottom: 1px solid #eeeeee;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aside-menu__item--danger {
|
|
||||||
color: $dangerColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aside-menu__item--push {
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aside-menu__item--danger:hover {
|
|
||||||
color: #ffffff;
|
|
||||||
background-color: $dangerColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aside-menu__item-text {
|
.aside-menu__item-text {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
|
@ -3,7 +3,6 @@ import {
|
||||||
faHome as overviewIcon,
|
faHome as overviewIcon,
|
||||||
faLink as createIcon,
|
faLink as createIcon,
|
||||||
faList as listIcon,
|
faList as listIcon,
|
||||||
faPen as editIcon,
|
|
||||||
faTags as tagsIcon,
|
faTags as tagsIcon,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
@ -11,13 +10,10 @@ import classNames from 'classnames';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import type { NavLinkProps } from 'react-router-dom';
|
import type { NavLinkProps } from 'react-router-dom';
|
||||||
import { NavLink, useLocation } from 'react-router-dom';
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
import type { SelectedServer } from '../servers/data';
|
|
||||||
import { isServerWithId } from '../servers/data';
|
|
||||||
import type { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
|
||||||
import './AsideMenu.scss';
|
import './AsideMenu.scss';
|
||||||
|
|
||||||
export interface AsideMenuProps {
|
export interface AsideMenuProps {
|
||||||
selectedServer: SelectedServer;
|
routePrefix: string;
|
||||||
showOnMobile?: boolean;
|
showOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,16 +32,12 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
export const AsideMenu: FC<AsideMenuProps> = ({ routePrefix, showOnMobile = false }) => {
|
||||||
{ selectedServer, showOnMobile = false }: AsideMenuProps,
|
|
||||||
) => {
|
|
||||||
const hasId = isServerWithId(selectedServer);
|
|
||||||
const serverId = hasId ? selectedServer.id : '';
|
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const asideClass = classNames('aside-menu', {
|
const asideClass = classNames('aside-menu', {
|
||||||
'aside-menu--hidden': !showOnMobile,
|
'aside-menu--hidden': !showOnMobile,
|
||||||
});
|
});
|
||||||
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
|
const buildPath = (suffix: string) => `${routePrefix}${suffix}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={asideClass}>
|
<aside className={asideClass}>
|
||||||
|
@ -73,17 +65,6 @@ export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||||
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
|
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
|
||||||
<span className="aside-menu__item-text">Manage domains</span>
|
<span className="aside-menu__item-text">Manage domains</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
|
||||||
<FontAwesomeIcon fixedWidth icon={editIcon} />
|
|
||||||
<span className="aside-menu__item-text">Edit this server</span>
|
|
||||||
</AsideMenuItem>
|
|
||||||
{hasId && (
|
|
||||||
<DeleteServerButton
|
|
||||||
className="aside-menu__item aside-menu__item--danger"
|
|
||||||
textClassName="aside-menu__item-text"
|
|
||||||
server={selectedServer}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
|
@ -1,5 +1,5 @@
|
||||||
import type { ProblemDetailsError } from './types/errors';
|
import type { ProblemDetailsError } from '../api-contract';
|
||||||
import { isInvalidArgumentError } from './utils';
|
import { isInvalidArgumentError } from '../api-contract/utils';
|
||||||
|
|
||||||
export interface ShlinkApiErrorProps {
|
export interface ShlinkApiErrorProps {
|
||||||
errorData?: ProblemDetailsError;
|
errorData?: ProblemDetailsError;
|
42
shlink-web-component/src/container/index.ts
Normal file
42
shlink-web-component/src/container/index.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import type { IContainer } from 'bottlejs';
|
||||||
|
import Bottle from 'bottlejs';
|
||||||
|
import { pick } from 'ramda';
|
||||||
|
import { connect as reduxConnect } from 'react-redux';
|
||||||
|
import { provideServices as provideDomainsServices } from '../domains/services/provideServices';
|
||||||
|
import { provideServices as provideMercureServices } from '../mercure/services/provideServices';
|
||||||
|
import { provideServices as provideOverviewServices } from '../overview/services/provideServices';
|
||||||
|
import { provideServices as provideShortUrlsServices } from '../short-urls/services/provideServices';
|
||||||
|
import { provideServices as provideTagsServices } from '../tags/services/provideServices';
|
||||||
|
import { provideServices as provideUtilsServices } from '../utils/services/provideServices';
|
||||||
|
import { provideServices as provideVisitsServices } from '../visits/services/provideServices';
|
||||||
|
import { provideServices as provideWebComponentServices } from './provideServices';
|
||||||
|
|
||||||
|
type LazyActionMap = Record<string, Function>;
|
||||||
|
|
||||||
|
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||||
|
|
||||||
|
export const bottle = new Bottle();
|
||||||
|
|
||||||
|
export const { container } = bottle;
|
||||||
|
|
||||||
|
const lazyService = <T extends Function, K>(cont: IContainer, serviceName: string) =>
|
||||||
|
(...args: any[]) => (cont[serviceName] as T)(...args) as K;
|
||||||
|
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
|
||||||
|
...map,
|
||||||
|
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
||||||
|
[actionName]: lazyService(container, actionName),
|
||||||
|
});
|
||||||
|
const connect: ConnectDecorator = (propsFromState: string[] | null, actionServiceNames: string[] = []) =>
|
||||||
|
reduxConnect(
|
||||||
|
propsFromState ? pick(propsFromState) : null,
|
||||||
|
actionServiceNames.reduce(mapActionService, {}),
|
||||||
|
);
|
||||||
|
|
||||||
|
provideWebComponentServices(bottle);
|
||||||
|
provideShortUrlsServices(bottle, connect);
|
||||||
|
provideTagsServices(bottle, connect);
|
||||||
|
provideVisitsServices(bottle, connect);
|
||||||
|
provideMercureServices(bottle);
|
||||||
|
provideDomainsServices(bottle, connect);
|
||||||
|
provideOverviewServices(bottle, connect);
|
||||||
|
provideUtilsServices(bottle);
|
23
shlink-web-component/src/container/provideServices.ts
Normal file
23
shlink-web-component/src/container/provideServices.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import type Bottle from 'bottlejs';
|
||||||
|
import { Main } from '../Main';
|
||||||
|
import { setUpStore } from './store';
|
||||||
|
|
||||||
|
export const provideServices = (bottle: Bottle) => {
|
||||||
|
bottle.serviceFactory(
|
||||||
|
'Main',
|
||||||
|
Main,
|
||||||
|
'TagsList',
|
||||||
|
'ShortUrlsList',
|
||||||
|
'CreateShortUrl',
|
||||||
|
'ShortUrlVisits',
|
||||||
|
'TagVisits',
|
||||||
|
'DomainVisits',
|
||||||
|
'OrphanVisits',
|
||||||
|
'NonOrphanVisits',
|
||||||
|
'Overview',
|
||||||
|
'EditShortUrl',
|
||||||
|
'ManageDomains',
|
||||||
|
);
|
||||||
|
|
||||||
|
bottle.factory('store', setUpStore);
|
||||||
|
};
|
65
shlink-web-component/src/container/store.ts
Normal file
65
shlink-web-component/src/container/store.ts
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import { combineReducers, configureStore } from '@reduxjs/toolkit';
|
||||||
|
import type { IContainer } from 'bottlejs';
|
||||||
|
import type { DomainsList } from '../domains/reducers/domainsList';
|
||||||
|
import type { MercureInfo } from '../mercure/reducers/mercureInfo';
|
||||||
|
import type { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
|
||||||
|
import type { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
|
||||||
|
import type { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
|
||||||
|
import type { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
|
||||||
|
import type { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
|
||||||
|
import type { TagDeletion } from '../tags/reducers/tagDelete';
|
||||||
|
import type { TagEdition } from '../tags/reducers/tagEdit';
|
||||||
|
import type { TagsList } from '../tags/reducers/tagsList';
|
||||||
|
import type { DomainVisits } from '../visits/reducers/domainVisits';
|
||||||
|
import type { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
||||||
|
import type { TagVisits } from '../visits/reducers/tagVisits';
|
||||||
|
import type { VisitsInfo } from '../visits/reducers/types';
|
||||||
|
import type { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
export const setUpStore = (container: IContainer) => configureStore({
|
||||||
|
devTools: !isProduction,
|
||||||
|
reducer: combineReducers({
|
||||||
|
mercureInfo: container.mercureInfoReducer,
|
||||||
|
shortUrlsList: container.shortUrlsListReducer,
|
||||||
|
shortUrlCreation: container.shortUrlCreationReducer,
|
||||||
|
shortUrlDeletion: container.shortUrlDeletionReducer,
|
||||||
|
shortUrlEdition: container.shortUrlEditionReducer,
|
||||||
|
shortUrlDetail: container.shortUrlDetailReducer,
|
||||||
|
shortUrlVisits: container.shortUrlVisitsReducer,
|
||||||
|
tagVisits: container.tagVisitsReducer,
|
||||||
|
domainVisits: container.domainVisitsReducer,
|
||||||
|
orphanVisits: container.orphanVisitsReducer,
|
||||||
|
nonOrphanVisits: container.nonOrphanVisitsReducer,
|
||||||
|
tagsList: container.tagsListReducer,
|
||||||
|
tagDelete: container.tagDeleteReducer,
|
||||||
|
tagEdit: container.tagEditReducer,
|
||||||
|
domainsList: container.domainsListReducer,
|
||||||
|
visitsOverview: container.visitsOverviewReducer,
|
||||||
|
}),
|
||||||
|
middleware: (defaultMiddlewaresIncludingReduxThunk) => defaultMiddlewaresIncludingReduxThunk({
|
||||||
|
// State is too big for these
|
||||||
|
immutableCheck: false,
|
||||||
|
serializableCheck: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RootState = {
|
||||||
|
shortUrlsList: ShortUrlsList;
|
||||||
|
shortUrlCreation: ShortUrlCreation;
|
||||||
|
shortUrlDeletion: ShortUrlDeletion;
|
||||||
|
shortUrlEdition: ShortUrlEdition;
|
||||||
|
shortUrlVisits: ShortUrlVisits;
|
||||||
|
tagVisits: TagVisits;
|
||||||
|
domainVisits: DomainVisits;
|
||||||
|
orphanVisits: VisitsInfo;
|
||||||
|
nonOrphanVisits: VisitsInfo;
|
||||||
|
shortUrlDetail: ShortUrlDetail;
|
||||||
|
tagsList: TagsList;
|
||||||
|
tagDelete: TagDeletion;
|
||||||
|
tagEdit: TagEdition;
|
||||||
|
mercureInfo: MercureInfo;
|
||||||
|
domainsList: DomainsList;
|
||||||
|
visitsOverview: VisitsOverview;
|
||||||
|
};
|
|
@ -3,9 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
import type { ShlinkDomainRedirects } from '../api/types';
|
import type { ShlinkDomainRedirects } from '../api-contract';
|
||||||
import type { SelectedServer } from '../servers/data';
|
|
||||||
import type { OptionalString } from '../utils/utils';
|
|
||||||
import type { Domain } from './data';
|
import type { Domain } from './data';
|
||||||
import { DomainDropdown } from './helpers/DomainDropdown';
|
import { DomainDropdown } from './helpers/DomainDropdown';
|
||||||
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
|
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
|
||||||
|
@ -16,10 +14,9 @@ interface DomainRowProps {
|
||||||
defaultRedirects?: ShlinkDomainRedirects;
|
defaultRedirects?: ShlinkDomainRedirects;
|
||||||
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
||||||
checkDomainHealth: (domain: string) => void;
|
checkDomainHealth: (domain: string) => void;
|
||||||
selectedServer: SelectedServer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
|
const Nr: FC<{ fallback?: string | null }> = ({ fallback }) => (
|
||||||
<span className="text-muted">
|
<span className="text-muted">
|
||||||
{!fallback && <small>No redirect</small>}
|
{!fallback && <small>No redirect</small>}
|
||||||
{fallback && <>{fallback} <small>(as fallback)</small></>}
|
{fallback && <>{fallback} <small>(as fallback)</small></>}
|
||||||
|
@ -33,7 +30,7 @@ const DefaultDomain: FC = () => (
|
||||||
);
|
);
|
||||||
|
|
||||||
export const DomainRow: FC<DomainRowProps> = (
|
export const DomainRow: FC<DomainRowProps> = (
|
||||||
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer },
|
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects },
|
||||||
) => {
|
) => {
|
||||||
const { domain: authority, isDefault, redirects, status } = domain;
|
const { domain: authority, isDefault, redirects, status } = domain;
|
||||||
|
|
||||||
|
@ -58,7 +55,7 @@ export const DomainRow: FC<DomainRowProps> = (
|
||||||
<DomainStatusIcon status={status} />
|
<DomainStatusIcon status={status} />
|
||||||
</td>
|
</td>
|
||||||
<td className="responsive-table__cell text-end">
|
<td className="responsive-table__cell text-end">
|
||||||
<DomainDropdown domain={domain} editDomainRedirects={editDomainRedirects} selectedServer={selectedServer} />
|
<DomainDropdown domain={domain} editDomainRedirects={editDomainRedirects} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../utils/base';
|
@import '@shlinkio/shlink-frontend-kit/base';
|
||||||
@import '../utils/mixins/vertical-align';
|
@import '../utils/mixins/vertical-align';
|
||||||
|
|
||||||
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn,
|
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn,
|
|
@ -1,11 +1,10 @@
|
||||||
import { faUndo } from '@fortawesome/free-solid-svg-icons';
|
import { faUndo } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { DropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { isEmpty, pipe } from 'ramda';
|
import { isEmpty, pipe } from 'ramda';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import type { InputProps } from 'reactstrap';
|
import type { InputProps } from 'reactstrap';
|
||||||
import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip } from 'reactstrap';
|
import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip } from 'reactstrap';
|
||||||
import { DropdownBtn } from '../utils/DropdownBtn';
|
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
|
||||||
import type { DomainsList } from './reducers/domainsList';
|
import type { DomainsList } from './reducers/domainsList';
|
||||||
import './DomainSelector.scss';
|
import './DomainSelector.scss';
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
|
import { Message, Result, SearchField, SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
import { ShlinkApiError } from '../common/ShlinkApiError';
|
||||||
import type { SelectedServer } from '../servers/data';
|
|
||||||
import { Message } from '../utils/Message';
|
|
||||||
import { Result } from '../utils/Result';
|
|
||||||
import { SearchField } from '../utils/SearchField';
|
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
|
||||||
import { DomainRow } from './DomainRow';
|
import { DomainRow } from './DomainRow';
|
||||||
import type { EditDomainRedirects } from './reducers/domainRedirects';
|
import type { EditDomainRedirects } from './reducers/domainRedirects';
|
||||||
import type { DomainsList } from './reducers/domainsList';
|
import type { DomainsList } from './reducers/domainsList';
|
||||||
|
@ -16,13 +12,12 @@ interface ManageDomainsProps {
|
||||||
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
||||||
checkDomainHealth: (domain: string) => void;
|
checkDomainHealth: (domain: string) => void;
|
||||||
domainsList: DomainsList;
|
domainsList: DomainsList;
|
||||||
selectedServer: SelectedServer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = ['', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', ''];
|
const headers = ['', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', ''];
|
||||||
|
|
||||||
export const ManageDomains: FC<ManageDomainsProps> = (
|
export const ManageDomains: FC<ManageDomainsProps> = (
|
||||||
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer },
|
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth },
|
||||||
) => {
|
) => {
|
||||||
const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList;
|
const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList;
|
||||||
const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
|
const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
|
||||||
|
@ -59,7 +54,6 @@ export const ManageDomains: FC<ManageDomainsProps> = (
|
||||||
editDomainRedirects={editDomainRedirects}
|
editDomainRedirects={editDomainRedirects}
|
||||||
checkDomainHealth={checkDomainHealth}
|
checkDomainHealth={checkDomainHealth}
|
||||||
defaultRedirects={resolvedDefaultRedirects}
|
defaultRedirects={resolvedDefaultRedirects}
|
||||||
selectedServer={selectedServer}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ShlinkDomain } from '../../api/types';
|
import type { ShlinkDomain } from '../../api-contract';
|
||||||
|
|
||||||
export type DomainStatus = 'validating' | 'valid' | 'invalid';
|
export type DomainStatus = 'validating' | 'valid' | 'invalid';
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
import { faChartPie as pieChartIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faChartPie as pieChartIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { RowDropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { DropdownItem } from 'reactstrap';
|
import { DropdownItem } from 'reactstrap';
|
||||||
import type { SelectedServer } from '../../servers/data';
|
import { useFeature } from '../../utils/features';
|
||||||
import { getServerId } from '../../servers/data';
|
import { useRoutesPrefix } from '../../utils/routesPrefix';
|
||||||
import { useFeature } from '../../utils/helpers/features';
|
|
||||||
import { useToggle } from '../../utils/helpers/hooks';
|
|
||||||
import { RowDropdownBtn } from '../../utils/RowDropdownBtn';
|
|
||||||
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
|
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
|
||||||
import type { Domain } from '../data';
|
import type { Domain } from '../data';
|
||||||
import type { EditDomainRedirects } from '../reducers/domainRedirects';
|
import type { EditDomainRedirects } from '../reducers/domainRedirects';
|
||||||
|
@ -16,27 +14,24 @@ import { EditDomainRedirectsModal } from './EditDomainRedirectsModal';
|
||||||
interface DomainDropdownProps {
|
interface DomainDropdownProps {
|
||||||
domain: Domain;
|
domain: Domain;
|
||||||
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
||||||
selectedServer: SelectedServer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedirects, selectedServer }) => {
|
export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedirects }) => {
|
||||||
const [isModalOpen, toggleModal] = useToggle();
|
const [isModalOpen, toggleModal] = useToggle();
|
||||||
const { isDefault } = domain;
|
const withVisits = useFeature('domainVisits');
|
||||||
const canBeEdited = !isDefault || useFeature('defaultDomainRedirectsEdition', selectedServer);
|
const routesPrefix = useRoutesPrefix();
|
||||||
const withVisits = useFeature('domainVisits', selectedServer);
|
|
||||||
const serverId = getServerId(selectedServer);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RowDropdownBtn>
|
<RowDropdownBtn>
|
||||||
{withVisits && (
|
{withVisits && (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
tag={Link}
|
tag={Link}
|
||||||
to={`/server/${serverId}/domain/${domain.domain}${domain.isDefault ? `_${DEFAULT_DOMAIN}` : ''}/visits`}
|
to={`${routesPrefix}/domain/${domain.domain}${domain.isDefault ? `_${DEFAULT_DOMAIN}` : ''}/visits`}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
)}
|
)}
|
||||||
<DropdownItem disabled={!canBeEdited} onClick={!canBeEdited ? undefined : toggleModal}>
|
<DropdownItem onClick={toggleModal}>
|
||||||
<FontAwesomeIcon fixedWidth icon={editIcon} /> Edit redirects
|
<FontAwesomeIcon fixedWidth icon={editIcon} /> Edit redirects
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
|
|
@ -4,11 +4,11 @@ import {
|
||||||
faTimes as invalidIcon,
|
faTimes as invalidIcon,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { useElementRef } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
import { useElementRef } from '../../utils/helpers/hooks';
|
|
||||||
import type { MediaMatcher } from '../../utils/types';
|
import type { MediaMatcher } from '../../utils/types';
|
||||||
import type { DomainStatus } from '../data';
|
import type { DomainStatus } from '../data';
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
|
import type { InputFormGroupProps } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import { InputFormGroup } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import type { ShlinkDomain } from '../../api/types';
|
import type { ShlinkDomain } from '../../api-contract';
|
||||||
import type { InputFormGroupProps } from '../../utils/forms/InputFormGroup';
|
import { InfoTooltip } from '../../utils/components/InfoTooltip';
|
||||||
import { InputFormGroup } from '../../utils/forms/InputFormGroup';
|
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/helpers';
|
||||||
import { InfoTooltip } from '../../utils/InfoTooltip';
|
|
||||||
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
|
|
||||||
import type { EditDomainRedirects } from '../reducers/domainRedirects';
|
import type { EditDomainRedirects } from '../reducers/domainRedirects';
|
||||||
|
|
||||||
interface EditDomainRedirectsModalProps {
|
interface EditDomainRedirectsModalProps {
|
20
shlink-web-component/src/domains/reducers/domainRedirects.ts
Normal file
20
shlink-web-component/src/domains/reducers/domainRedirects.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import type { ShlinkApiClient, ShlinkDomainRedirects } from '../../api-contract';
|
||||||
|
import { createAsyncThunk } from '../../utils/redux';
|
||||||
|
|
||||||
|
const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
|
||||||
|
|
||||||
|
export interface EditDomainRedirects {
|
||||||
|
domain: string;
|
||||||
|
redirects: ShlinkDomainRedirects;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const editDomainRedirects = (
|
||||||
|
apiClientFactory: () => ShlinkApiClient,
|
||||||
|
) => createAsyncThunk(
|
||||||
|
EDIT_DOMAIN_REDIRECTS,
|
||||||
|
async ({ domain, redirects: providedRedirects }: EditDomainRedirects): Promise<EditDomainRedirects> => {
|
||||||
|
const apiClient = apiClientFactory();
|
||||||
|
const redirects = await apiClient.editDomainRedirects({ domain, ...providedRedirects });
|
||||||
|
return { domain, redirects };
|
||||||
|
},
|
||||||
|
);
|
|
@ -1,12 +1,8 @@
|
||||||
import type { AsyncThunk, SliceCaseReducers } from '@reduxjs/toolkit';
|
import type { AsyncThunk, SliceCaseReducers } from '@reduxjs/toolkit';
|
||||||
import { createAction, createSlice } from '@reduxjs/toolkit';
|
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||||
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import type { ProblemDetailsError, ShlinkApiClient, ShlinkDomainRedirects } from '../../api-contract';
|
||||||
import type { ShlinkDomainRedirects } from '../../api/types';
|
import { parseApiError } from '../../api-contract/utils';
|
||||||
import type { ProblemDetailsError } from '../../api/types/errors';
|
import { createAsyncThunk } from '../../utils/redux';
|
||||||
import { parseApiError } from '../../api/utils';
|
|
||||||
import { hasServerData } from '../../servers/data';
|
|
||||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
|
||||||
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
|
|
||||||
import type { Domain, DomainStatus } from '../data';
|
import type { Domain, DomainStatus } from '../data';
|
||||||
import type { EditDomainRedirects } from './domainRedirects';
|
import type { EditDomainRedirects } from './domainRedirects';
|
||||||
|
|
||||||
|
@ -45,12 +41,11 @@ export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
|
||||||
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, status });
|
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, status });
|
||||||
|
|
||||||
export const domainsListReducerCreator = (
|
export const domainsListReducerCreator = (
|
||||||
buildShlinkApiClient: ShlinkApiClientBuilder,
|
apiClientFactory: () => ShlinkApiClient,
|
||||||
editDomainRedirects: AsyncThunk<EditDomainRedirects, any, any>,
|
editDomainRedirects: AsyncThunk<EditDomainRedirects, any, any>,
|
||||||
) => {
|
) => {
|
||||||
const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (_: void, { getState }): Promise<ListDomains> => {
|
const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (): Promise<ListDomains> => {
|
||||||
const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState);
|
const { data, defaultRedirects } = await apiClientFactory().listDomains();
|
||||||
const { data, defaultRedirects } = await shlinkListDomains();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
|
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
|
||||||
|
@ -60,22 +55,9 @@ export const domainsListReducerCreator = (
|
||||||
|
|
||||||
const checkDomainHealth = createAsyncThunk(
|
const checkDomainHealth = createAsyncThunk(
|
||||||
`${REDUCER_PREFIX}/checkDomainHealth`,
|
`${REDUCER_PREFIX}/checkDomainHealth`,
|
||||||
async (domain: string, { getState }): Promise<ValidateDomain> => {
|
async (domain: string): Promise<ValidateDomain> => {
|
||||||
const { selectedServer } = getState();
|
|
||||||
|
|
||||||
if (!hasServerData(selectedServer)) {
|
|
||||||
return { domain, status: 'invalid' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { url, ...rest } = selectedServer;
|
const { status } = await apiClientFactory().health(domain);
|
||||||
const { health } = buildShlinkApiClient({
|
|
||||||
...rest,
|
|
||||||
url: replaceAuthorityFromUri(url, domain),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { status } = await health();
|
|
||||||
|
|
||||||
return { domain, status: status === 'pass' ? 'valid' : 'invalid' };
|
return { domain, status: status === 'pass' ? 'valid' : 'invalid' };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { domain, status: 'invalid' };
|
return { domain, status: 'invalid' };
|
|
@ -1,6 +1,6 @@
|
||||||
import type Bottle from 'bottlejs';
|
import type Bottle from 'bottlejs';
|
||||||
import { prop } from 'ramda';
|
import { prop } from 'ramda';
|
||||||
import type { ConnectDecorator } from '../../container/types';
|
import type { ConnectDecorator } from '../../container';
|
||||||
import { DomainSelector } from '../DomainSelector';
|
import { DomainSelector } from '../DomainSelector';
|
||||||
import { ManageDomains } from '../ManageDomains';
|
import { ManageDomains } from '../ManageDomains';
|
||||||
import { editDomainRedirects } from '../reducers/domainRedirects';
|
import { editDomainRedirects } from '../reducers/domainRedirects';
|
||||||
|
@ -13,7 +13,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
|
||||||
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
||||||
bottle.decorator('ManageDomains', connect(
|
bottle.decorator('ManageDomains', connect(
|
||||||
['domainsList', 'selectedServer'],
|
['domainsList'],
|
||||||
['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'],
|
['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'],
|
||||||
));
|
));
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
bottle.serviceFactory(
|
bottle.serviceFactory(
|
||||||
'domainsListReducerCreator',
|
'domainsListReducerCreator',
|
||||||
domainsListReducerCreator,
|
domainsListReducerCreator,
|
||||||
'buildShlinkApiClient',
|
'apiClientFactory',
|
||||||
'editDomainRedirects',
|
'editDomainRedirects',
|
||||||
);
|
);
|
||||||
bottle.serviceFactory('domainsListReducer', prop('reducer'), 'domainsListReducerCreator');
|
bottle.serviceFactory('domainsListReducer', prop('reducer'), 'domainsListReducerCreator');
|
||||||
|
@ -29,6 +29,6 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('listDomains', prop('listDomains'), 'domainsListReducerCreator');
|
bottle.serviceFactory('listDomains', prop('listDomains'), 'domainsListReducerCreator');
|
||||||
bottle.serviceFactory('filterDomains', prop('filterDomains'), 'domainsListReducerCreator');
|
bottle.serviceFactory('filterDomains', prop('filterDomains'), 'domainsListReducerCreator');
|
||||||
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'apiClientFactory');
|
||||||
bottle.serviceFactory('checkDomainHealth', prop('checkDomainHealth'), 'domainsListReducerCreator');
|
bottle.serviceFactory('checkDomainHealth', prop('checkDomainHealth'), 'domainsListReducerCreator');
|
||||||
};
|
};
|
2
shlink-web-component/src/index.scss
Normal file
2
shlink-web-component/src/index.scss
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
@import './tags/react-tag-autocomplete';
|
||||||
|
@import './utils/StickyCardPaginator.scss';
|
18
shlink-web-component/src/index.ts
Normal file
18
shlink-web-component/src/index.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { bottle } from './container';
|
||||||
|
import { createShlinkWebComponent } from './ShlinkWebComponent';
|
||||||
|
|
||||||
|
export const ShlinkWebComponent = createShlinkWebComponent(bottle);
|
||||||
|
|
||||||
|
export type ShlinkWebComponentType = typeof ShlinkWebComponent;
|
||||||
|
|
||||||
|
export type {
|
||||||
|
RealTimeUpdatesSettings,
|
||||||
|
ShortUrlCreationSettings,
|
||||||
|
ShortUrlsListSettings,
|
||||||
|
UiSettings,
|
||||||
|
VisitsSettings,
|
||||||
|
TagsSettings,
|
||||||
|
Settings,
|
||||||
|
} from './utils/settings';
|
||||||
|
|
||||||
|
export type { TagColorsStorage } from './utils/services/TagColorsStorage';
|
|
@ -23,6 +23,7 @@ export function boundToMercureHub<T = {}>(
|
||||||
const { interval } = mercureInfo;
|
const { interval } = mercureInfo;
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
|
// Every time mercure info changes, re-bind
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMessage = (visit: CreateVisit) => (interval ? pendingUpdates.add(visit) : createNewVisits([visit]));
|
const onMessage = (visit: CreateVisit) => (interval ? pendingUpdates.add(visit) : createNewVisits([visit]));
|
||||||
const topics = getTopicsForProps(props, params);
|
const topics = getTopicsForProps(props, params);
|
|
@ -1,7 +1,7 @@
|
||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import type { ShlinkApiClient, ShlinkMercureInfo } from '../../api-contract';
|
||||||
import type { ShlinkMercureInfo } from '../../api/types';
|
import { createAsyncThunk } from '../../utils/redux';
|
||||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
import type { Settings } from '../../utils/settings';
|
||||||
|
|
||||||
const REDUCER_PREFIX = 'shlink/mercure';
|
const REDUCER_PREFIX = 'shlink/mercure';
|
||||||
|
|
||||||
|
@ -16,16 +16,15 @@ const initialState: MercureInfo = {
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mercureInfoReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => {
|
export const mercureInfoReducerCreator = (apiClientFactory: () => ShlinkApiClient) => {
|
||||||
const loadMercureInfo = createAsyncThunk(
|
const loadMercureInfo = createAsyncThunk(
|
||||||
`${REDUCER_PREFIX}/loadMercureInfo`,
|
`${REDUCER_PREFIX}/loadMercureInfo`,
|
||||||
(_: void, { getState }): Promise<ShlinkMercureInfo> => {
|
({ realTimeUpdates }: Settings): Promise<ShlinkMercureInfo> => {
|
||||||
const { settings } = getState();
|
if (realTimeUpdates && !realTimeUpdates.enabled) {
|
||||||
if (!settings.realTimeUpdates.enabled) {
|
|
||||||
throw new Error('Real time updates not enabled');
|
throw new Error('Real time updates not enabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildShlinkApiClient(getState).mercureInfo();
|
return apiClientFactory().mercureInfo();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { mercureInfoReducerCreator } from '../reducers/mercureInfo';
|
||||||
|
|
||||||
export const provideServices = (bottle: Bottle) => {
|
export const provideServices = (bottle: Bottle) => {
|
||||||
// Reducer
|
// Reducer
|
||||||
bottle.serviceFactory('mercureInfoReducerCreator', mercureInfoReducerCreator, 'buildShlinkApiClient');
|
bottle.serviceFactory('mercureInfoReducerCreator', mercureInfoReducerCreator, 'apiClientFactory');
|
||||||
bottle.serviceFactory('mercureInfoReducer', prop('reducer'), 'mercureInfoReducerCreator');
|
bottle.serviceFactory('mercureInfoReducer', prop('reducer'), 'mercureInfoReducerCreator');
|
||||||
|
|
||||||
// Actions
|
// Actions
|
|
@ -2,20 +2,18 @@ import type { FC } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { Card, CardBody, CardHeader, Row } from 'reactstrap';
|
import { Card, CardBody, CardHeader, Row } from 'reactstrap';
|
||||||
import type { ShlinkShortUrlsListParams } from '../api/types';
|
import type { ShlinkShortUrlsListParams } from '../api-contract';
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
import type { Settings } from '../settings/reducers/settings';
|
|
||||||
import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
||||||
import type { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
import type { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
||||||
import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList';
|
import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList';
|
||||||
import type { ShortUrlsTableType } from '../short-urls/ShortUrlsTable';
|
import type { ShortUrlsTableType } from '../short-urls/ShortUrlsTable';
|
||||||
import type { TagsList } from '../tags/reducers/tagsList';
|
import type { TagsList } from '../tags/reducers/tagsList';
|
||||||
import { useFeature } from '../utils/helpers/features';
|
|
||||||
import { prettify } from '../utils/helpers/numbers';
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
|
import { useRoutesPrefix } from '../utils/routesPrefix';
|
||||||
|
import { useSetting } from '../utils/settings';
|
||||||
import type { VisitsOverview } from '../visits/reducers/visitsOverview';
|
import type { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||||
import type { SelectedServer } from './data';
|
|
||||||
import { getServerId } from './data';
|
|
||||||
import { HighlightCard } from './helpers/HighlightCard';
|
import { HighlightCard } from './helpers/HighlightCard';
|
||||||
import { VisitsHighlightCard } from './helpers/VisitsHighlightCard';
|
import { VisitsHighlightCard } from './helpers/VisitsHighlightCard';
|
||||||
|
|
||||||
|
@ -24,10 +22,8 @@ interface OverviewConnectProps {
|
||||||
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
||||||
listTags: Function;
|
listTags: Function;
|
||||||
tagsList: TagsList;
|
tagsList: TagsList;
|
||||||
selectedServer: SelectedServer;
|
|
||||||
visitsOverview: VisitsOverview;
|
visitsOverview: VisitsOverview;
|
||||||
loadVisitsOverview: Function;
|
loadVisitsOverview: Function;
|
||||||
settings: Settings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Overview = (
|
export const Overview = (
|
||||||
|
@ -38,17 +34,15 @@ export const Overview = (
|
||||||
listShortUrls,
|
listShortUrls,
|
||||||
listTags,
|
listTags,
|
||||||
tagsList,
|
tagsList,
|
||||||
selectedServer,
|
|
||||||
loadVisitsOverview,
|
loadVisitsOverview,
|
||||||
visitsOverview,
|
visitsOverview,
|
||||||
settings: { visits },
|
|
||||||
}: OverviewConnectProps) => {
|
}: OverviewConnectProps) => {
|
||||||
const { loading, shortUrls } = shortUrlsList;
|
const { loading, shortUrls } = shortUrlsList;
|
||||||
const { loading: loadingTags } = tagsList;
|
const { loading: loadingTags } = tagsList;
|
||||||
const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview;
|
const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview;
|
||||||
const serverId = getServerId(selectedServer);
|
const routesPrefix = useRoutesPrefix();
|
||||||
const linkToNonOrphanVisits = useFeature('nonOrphanVisits', selectedServer);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const visits = useSetting('visits');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } });
|
listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } });
|
||||||
|
@ -62,7 +56,7 @@ export const Overview = (
|
||||||
<div className="col-lg-6 col-xl-3 mb-3">
|
<div className="col-lg-6 col-xl-3 mb-3">
|
||||||
<VisitsHighlightCard
|
<VisitsHighlightCard
|
||||||
title="Visits"
|
title="Visits"
|
||||||
link={linkToNonOrphanVisits ? `/server/${serverId}/non-orphan-visits` : undefined}
|
link={`${routesPrefix}/non-orphan-visits`}
|
||||||
excludeBots={visits?.excludeBots ?? false}
|
excludeBots={visits?.excludeBots ?? false}
|
||||||
loading={loadingVisits}
|
loading={loadingVisits}
|
||||||
visitsSummary={nonOrphanVisits}
|
visitsSummary={nonOrphanVisits}
|
||||||
|
@ -71,19 +65,19 @@ export const Overview = (
|
||||||
<div className="col-lg-6 col-xl-3 mb-3">
|
<div className="col-lg-6 col-xl-3 mb-3">
|
||||||
<VisitsHighlightCard
|
<VisitsHighlightCard
|
||||||
title="Orphan visits"
|
title="Orphan visits"
|
||||||
link={`/server/${serverId}/orphan-visits`}
|
link={`${routesPrefix}/orphan-visits`}
|
||||||
excludeBots={visits?.excludeBots ?? false}
|
excludeBots={visits?.excludeBots ?? false}
|
||||||
loading={loadingVisits}
|
loading={loadingVisits}
|
||||||
visitsSummary={orphanVisits}
|
visitsSummary={orphanVisits}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-lg-6 col-xl-3 mb-3">
|
<div className="col-lg-6 col-xl-3 mb-3">
|
||||||
<HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}>
|
<HighlightCard title="Short URLs" link={`${routesPrefix}/list-short-urls/1`}>
|
||||||
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
|
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
|
||||||
</HighlightCard>
|
</HighlightCard>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-lg-6 col-xl-3 mb-3">
|
<div className="col-lg-6 col-xl-3 mb-3">
|
||||||
<HighlightCard title="Tags" link={`/server/${serverId}/manage-tags`}>
|
<HighlightCard title="Tags" link={`${routesPrefix}/manage-tags`}>
|
||||||
{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}
|
{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}
|
||||||
</HighlightCard>
|
</HighlightCard>
|
||||||
</div>
|
</div>
|
||||||
|
@ -93,7 +87,7 @@ export const Overview = (
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<span className="d-sm-none">Create a short URL</span>
|
<span className="d-sm-none">Create a short URL</span>
|
||||||
<h5 className="d-none d-sm-inline">Create a short URL</h5>
|
<h5 className="d-none d-sm-inline">Create a short URL</h5>
|
||||||
<Link className="float-end" to={`/server/${serverId}/create-short-url`}>Advanced options »</Link>
|
<Link className="float-end" to={`${routesPrefix}/create-short-url`}>Advanced options »</Link>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<CreateShortUrl basicMode />
|
<CreateShortUrl basicMode />
|
||||||
|
@ -103,14 +97,13 @@ export const Overview = (
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<span className="d-sm-none">Recently created URLs</span>
|
<span className="d-sm-none">Recently created URLs</span>
|
||||||
<h5 className="d-none d-sm-inline">Recently created URLs</h5>
|
<h5 className="d-none d-sm-inline">Recently created URLs</h5>
|
||||||
<Link className="float-end" to={`/server/${serverId}/list-short-urls/1`}>See all »</Link>
|
<Link className="float-end" to={`${routesPrefix}/list-short-urls/1`}>See all »</Link>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<ShortUrlsTable
|
<ShortUrlsTable
|
||||||
shortUrlsList={shortUrlsList}
|
shortUrlsList={shortUrlsList}
|
||||||
selectedServer={selectedServer}
|
|
||||||
className="mb-0"
|
className="mb-0"
|
||||||
onTagClick={(tag) => navigate(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
|
onTagClick={(tag) => navigate(`${routesPrefix}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
|
||||||
/>
|
/>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../../utils/base';
|
@import '@shlinkio/shlink-frontend-kit/base';
|
||||||
|
|
||||||
.highlight-card.highlight-card {
|
.highlight-card.highlight-card {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
@ -11,7 +11,7 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 5px;
|
right: 5px;
|
||||||
bottom: 5px;
|
bottom: 5px;
|
||||||
opacity: 0.1;
|
opacity: .1;
|
||||||
transform: rotate(-45deg);
|
transform: rotate(-45deg);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons';
|
import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { useElementRef } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Card, CardText, CardTitle, UncontrolledTooltip } from 'reactstrap';
|
import { Card, CardText, CardTitle, UncontrolledTooltip } from 'reactstrap';
|
||||||
import { useElementRef } from '../../utils/helpers/hooks';
|
|
||||||
import './HighlightCard.scss';
|
import './HighlightCard.scss';
|
||||||
|
|
||||||
export type HighlightCardProps = PropsWithChildren<{
|
export type HighlightCardProps = PropsWithChildren<{
|
||||||
title: string;
|
title: string;
|
||||||
link?: string;
|
link: string;
|
||||||
tooltip?: ReactNode;
|
tooltip?: ReactNode;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const buildExtraProps = (link?: string) => (!link ? {} : { tag: Link, to: link });
|
const buildExtraProps = (link: string) => ({ tag: Link, to: link });
|
||||||
|
|
||||||
export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link, tooltip }) => {
|
export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link, tooltip }) => {
|
||||||
const ref = useElementRef<HTMLElement>();
|
const ref = useElementRef<HTMLElement>();
|
||||||
|
@ -20,7 +20,7 @@ export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link, t
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card innerRef={ref} className="highlight-card" body {...buildExtraProps(link)}>
|
<Card innerRef={ref} className="highlight-card" body {...buildExtraProps(link)}>
|
||||||
{link && <FontAwesomeIcon size="3x" className="highlight-card__link-icon" icon={linkIcon} />}
|
<FontAwesomeIcon size="3x" className="highlight-card__link-icon" icon={linkIcon} />
|
||||||
<CardTitle tag="h5" className="highlight-card__title">{title}</CardTitle>
|
<CardTitle tag="h5" className="highlight-card__title">{title}</CardTitle>
|
||||||
<CardText tag="h2">{children}</CardText>
|
<CardText tag="h2">{children}</CardText>
|
||||||
</Card>
|
</Card>
|
|
@ -14,7 +14,7 @@ export const VisitsHighlightCard: FC<VisitsHighlightCardProps> = ({ loading, exc
|
||||||
<HighlightCard
|
<HighlightCard
|
||||||
tooltip={
|
tooltip={
|
||||||
visitsSummary.bots !== undefined
|
visitsSummary.bots !== undefined
|
||||||
? <>{excludeBots ? 'Plus' : 'Including'} <b>{prettify(visitsSummary.bots)}</b> potential bot visits</>
|
? <>{excludeBots ? 'Plus' : 'Including'} <strong>{prettify(visitsSummary.bots)}</strong> potential bot visits</>
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
{...rest}
|
{...rest}
|
|
@ -0,0 +1,11 @@
|
||||||
|
import type Bottle from 'bottlejs';
|
||||||
|
import type { ConnectDecorator } from '../../container';
|
||||||
|
import { Overview } from '../Overview';
|
||||||
|
|
||||||
|
export function provideServices(bottle: Bottle, connect: ConnectDecorator) {
|
||||||
|
bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl');
|
||||||
|
bottle.decorator('Overview', connect(
|
||||||
|
['shortUrlsList', 'tagsList', 'mercureInfo', 'visitsOverview'],
|
||||||
|
['listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview'],
|
||||||
|
));
|
||||||
|
}
|
|
@ -1,8 +1,8 @@
|
||||||
|
import type { ShlinkCreateShortUrlData } from '@shlinkio/shlink-web-component/api-contract';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { SelectedServer } from '../servers/data';
|
import type { ShortUrlCreationSettings } from '../utils/settings';
|
||||||
import type { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings';
|
import { useSetting } from '../utils/settings';
|
||||||
import type { ShortUrlData } from './data';
|
|
||||||
import type { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
|
import type { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
|
||||||
import type { ShortUrlCreation } from './reducers/shortUrlCreation';
|
import type { ShortUrlCreation } from './reducers/shortUrlCreation';
|
||||||
import type { ShortUrlFormProps } from './ShortUrlForm';
|
import type { ShortUrlFormProps } from './ShortUrlForm';
|
||||||
|
@ -12,14 +12,12 @@ export interface CreateShortUrlProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateShortUrlConnectProps extends CreateShortUrlProps {
|
interface CreateShortUrlConnectProps extends CreateShortUrlProps {
|
||||||
settings: Settings;
|
|
||||||
shortUrlCreation: ShortUrlCreation;
|
shortUrlCreation: ShortUrlCreation;
|
||||||
selectedServer: SelectedServer;
|
createShortUrl: (data: ShlinkCreateShortUrlData) => Promise<void>;
|
||||||
createShortUrl: (data: ShortUrlData) => Promise<void>;
|
|
||||||
resetCreateShortUrl: () => void;
|
resetCreateShortUrl: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => ({
|
const getInitialState = (settings?: ShortUrlCreationSettings): ShlinkCreateShortUrlData => ({
|
||||||
longUrl: '',
|
longUrl: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
customSlug: '',
|
customSlug: '',
|
||||||
|
@ -35,16 +33,15 @@ const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => (
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CreateShortUrl = (
|
export const CreateShortUrl = (
|
||||||
ShortUrlForm: FC<ShortUrlFormProps>,
|
ShortUrlForm: FC<ShortUrlFormProps<ShlinkCreateShortUrlData>>,
|
||||||
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
|
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
|
||||||
) => ({
|
) => ({
|
||||||
createShortUrl,
|
createShortUrl,
|
||||||
shortUrlCreation,
|
shortUrlCreation,
|
||||||
resetCreateShortUrl,
|
resetCreateShortUrl,
|
||||||
selectedServer,
|
|
||||||
basicMode = false,
|
basicMode = false,
|
||||||
settings: { shortUrlCreation: shortUrlCreationSettings },
|
|
||||||
}: CreateShortUrlConnectProps) => {
|
}: CreateShortUrlConnectProps) => {
|
||||||
|
const shortUrlCreationSettings = useSetting('shortUrlCreation');
|
||||||
const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [shortUrlCreationSettings]);
|
const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [shortUrlCreationSettings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -52,9 +49,8 @@ export const CreateShortUrl = (
|
||||||
<ShortUrlForm
|
<ShortUrlForm
|
||||||
initialState={initialState}
|
initialState={initialState}
|
||||||
saving={shortUrlCreation.saving}
|
saving={shortUrlCreation.saving}
|
||||||
selectedServer={selectedServer}
|
|
||||||
mode={basicMode ? 'create-basic' : 'create'}
|
mode={basicMode ? 'create-basic' : 'create'}
|
||||||
onSave={async (data: ShortUrlData) => {
|
onSave={async (data) => {
|
||||||
resetCreateShortUrl();
|
resetCreateShortUrl();
|
||||||
return createShortUrl(data);
|
return createShortUrl(data);
|
||||||
}}
|
}}
|
|
@ -1,17 +1,15 @@
|
||||||
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
|
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { Message, parseQuery, Result } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import type { ShlinkEditShortUrlData } from '@shlinkio/shlink-web-component/api-contract';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { useLocation, useParams } from 'react-router-dom';
|
import { useLocation, useParams } from 'react-router-dom';
|
||||||
import { Button, Card } from 'reactstrap';
|
import { Button, Card } from 'reactstrap';
|
||||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
import { ShlinkApiError } from '../common/ShlinkApiError';
|
||||||
import type { SelectedServer } from '../servers/data';
|
|
||||||
import type { Settings } from '../settings/reducers/settings';
|
|
||||||
import { useGoBack } from '../utils/helpers/hooks';
|
import { useGoBack } from '../utils/helpers/hooks';
|
||||||
import { parseQuery } from '../utils/helpers/query';
|
import { useSetting } from '../utils/settings';
|
||||||
import { Message } from '../utils/Message';
|
|
||||||
import { Result } from '../utils/Result';
|
|
||||||
import type { ShortUrlIdentifier } from './data';
|
import type { ShortUrlIdentifier } from './data';
|
||||||
import { shortUrlDataFromShortUrl, urlDecodeShortCode } from './helpers';
|
import { shortUrlDataFromShortUrl, urlDecodeShortCode } from './helpers';
|
||||||
import type { ShortUrlDetail } from './reducers/shortUrlDetail';
|
import type { ShortUrlDetail } from './reducers/shortUrlDetail';
|
||||||
|
@ -19,17 +17,13 @@ import type { EditShortUrl as EditShortUrlInfo, ShortUrlEdition } from './reduce
|
||||||
import type { ShortUrlFormProps } from './ShortUrlForm';
|
import type { ShortUrlFormProps } from './ShortUrlForm';
|
||||||
|
|
||||||
interface EditShortUrlConnectProps {
|
interface EditShortUrlConnectProps {
|
||||||
settings: Settings;
|
|
||||||
selectedServer: SelectedServer;
|
|
||||||
shortUrlDetail: ShortUrlDetail;
|
shortUrlDetail: ShortUrlDetail;
|
||||||
shortUrlEdition: ShortUrlEdition;
|
shortUrlEdition: ShortUrlEdition;
|
||||||
getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void;
|
getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void;
|
||||||
editShortUrl: (editShortUrl: EditShortUrlInfo) => void;
|
editShortUrl: (editShortUrl: EditShortUrlInfo) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps<ShlinkEditShortUrlData>>) => ({
|
||||||
settings: { shortUrlCreation: shortUrlCreationSettings },
|
|
||||||
selectedServer,
|
|
||||||
shortUrlDetail,
|
shortUrlDetail,
|
||||||
getShortUrlDetail,
|
getShortUrlDetail,
|
||||||
shortUrlEdition,
|
shortUrlEdition,
|
||||||
|
@ -41,6 +35,7 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
||||||
const { loading, error, errorData, shortUrl } = shortUrlDetail;
|
const { loading, error, errorData, shortUrl } = shortUrlDetail;
|
||||||
const { saving, saved, error: savingError, errorData: savingErrorData } = shortUrlEdition;
|
const { saving, saved, error: savingError, errorData: savingErrorData } = shortUrlEdition;
|
||||||
const { domain } = parseQuery<{ domain?: string }>(search);
|
const { domain } = parseQuery<{ domain?: string }>(search);
|
||||||
|
const shortUrlCreationSettings = useSetting('shortUrlCreation');
|
||||||
const initialState = useMemo(
|
const initialState = useMemo(
|
||||||
() => shortUrlDataFromShortUrl(shortUrl, shortUrlCreationSettings),
|
() => shortUrlDataFromShortUrl(shortUrl, shortUrlCreationSettings),
|
||||||
[shortUrl, shortUrlCreationSettings],
|
[shortUrl, shortUrlCreationSettings],
|
||||||
|
@ -80,7 +75,6 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
||||||
<ShortUrlForm
|
<ShortUrlForm
|
||||||
initialState={initialState}
|
initialState={initialState}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
selectedServer={selectedServer}
|
|
||||||
mode="edit"
|
mode="edit"
|
||||||
onSave={async (shortUrlData) => {
|
onSave={async (shortUrlData) => {
|
||||||
if (!shortUrl) {
|
if (!shortUrl) {
|
|
@ -1,6 +1,6 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||||
import type { ShlinkPaginator } from '../api/types';
|
import type { ShlinkPaginator } from '../api-contract';
|
||||||
import type {
|
import type {
|
||||||
NumberOrEllipsis } from '../utils/helpers/pagination';
|
NumberOrEllipsis } from '../utils/helpers/pagination';
|
||||||
import {
|
import {
|
||||||
|
@ -9,17 +9,18 @@ import {
|
||||||
prettifyPageNumber,
|
prettifyPageNumber,
|
||||||
progressivePagination,
|
progressivePagination,
|
||||||
} from '../utils/helpers/pagination';
|
} from '../utils/helpers/pagination';
|
||||||
|
import { useRoutesPrefix } from '../utils/routesPrefix';
|
||||||
|
|
||||||
interface PaginatorProps {
|
interface PaginatorProps {
|
||||||
paginator?: ShlinkPaginator;
|
paginator?: ShlinkPaginator;
|
||||||
serverId: string;
|
|
||||||
currentQueryString?: string;
|
currentQueryString?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Paginator = ({ paginator, serverId, currentQueryString = '' }: PaginatorProps) => {
|
export const Paginator = ({ paginator, currentQueryString = '' }: PaginatorProps) => {
|
||||||
const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
|
const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
|
||||||
|
const routesPrefix = useRoutesPrefix();
|
||||||
const urlForPage = (pageNumber: NumberOrEllipsis) =>
|
const urlForPage = (pageNumber: NumberOrEllipsis) =>
|
||||||
`/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`;
|
`${routesPrefix}/list-short-urls/${pageNumber}${currentQueryString}`;
|
||||||
|
|
||||||
if (pagesCount <= 1) {
|
if (pagesCount <= 1) {
|
||||||
return <div className="pb-3" />; // Return some space
|
return <div className="pb-3" />; // Return some space
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../utils/base';
|
@import '@shlinkio/shlink-frontend-kit/base';
|
||||||
|
|
||||||
.short-url-form p:last-child {
|
.short-url-form p:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
|
@ -1,6 +1,7 @@
|
||||||
import type { IconProp } from '@fortawesome/fontawesome-svg-core';
|
import type { IconProp } from '@fortawesome/fontawesome-svg-core';
|
||||||
import { faAndroid, faApple } from '@fortawesome/free-brands-svg-icons';
|
import { faAndroid, faApple } from '@fortawesome/free-brands-svg-icons';
|
||||||
import { faDesktop } from '@fortawesome/free-solid-svg-icons';
|
import { faDesktop } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { Checkbox, SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
import { isEmpty, pipe, replace, trim } from 'ramda';
|
import { isEmpty, pipe, replace, trim } from 'ramda';
|
||||||
|
@ -8,18 +9,15 @@ import type { ChangeEvent, FC } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, FormGroup, Input, Row } from 'reactstrap';
|
import { Button, FormGroup, Input, Row } from 'reactstrap';
|
||||||
import type { InputType } from 'reactstrap/types/lib/Input';
|
import type { InputType } from 'reactstrap/types/lib/Input';
|
||||||
|
import type { ShlinkCreateShortUrlData, ShlinkDeviceLongUrls, ShlinkEditShortUrlData } from '../api-contract';
|
||||||
import type { DomainSelectorProps } from '../domains/DomainSelector';
|
import type { DomainSelectorProps } from '../domains/DomainSelector';
|
||||||
import type { SelectedServer } from '../servers/data';
|
|
||||||
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||||
import { Checkbox } from '../utils/Checkbox';
|
import { IconInput } from '../utils/components/IconInput';
|
||||||
import type { DateTimeInputProps } from '../utils/dates/DateTimeInput';
|
import type { DateTimeInputProps } from '../utils/dates/DateTimeInput';
|
||||||
import { DateTimeInput } from '../utils/dates/DateTimeInput';
|
import { DateTimeInput } from '../utils/dates/DateTimeInput';
|
||||||
import { formatIsoDate } from '../utils/helpers/date';
|
import { formatIsoDate } from '../utils/dates/helpers/date';
|
||||||
import { useFeature } from '../utils/helpers/features';
|
import { useFeature } from '../utils/features';
|
||||||
import { IconInput } from '../utils/IconInput';
|
import { handleEventPreventingDefault, hasValue } from '../utils/helpers';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
|
||||||
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
|
|
||||||
import type { DeviceLongUrls, ShortUrlData } from './data';
|
|
||||||
import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup';
|
import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup';
|
||||||
import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon';
|
import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon';
|
||||||
import './ShortUrlForm.scss';
|
import './ShortUrlForm.scss';
|
||||||
|
@ -29,26 +27,32 @@ export type Mode = 'create' | 'create-basic' | 'edit';
|
||||||
type DateFields = 'validSince' | 'validUntil';
|
type DateFields = 'validSince' | 'validUntil';
|
||||||
type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits' | 'title';
|
type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits' | 'title';
|
||||||
|
|
||||||
export interface ShortUrlFormProps {
|
export interface ShortUrlFormProps<T extends ShlinkCreateShortUrlData | ShlinkEditShortUrlData> {
|
||||||
|
// FIXME Try to get rid of the mode param, and infer creation or edition from initialState if possible
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
initialState: ShortUrlData;
|
initialState: T;
|
||||||
onSave: (shortUrlData: ShortUrlData) => Promise<unknown>;
|
onSave: (shortUrlData: T) => Promise<unknown>;
|
||||||
selectedServer: SelectedServer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||||
const toDate = (date?: string | Date): Date | undefined => (typeof date === 'string' ? parseISO(date) : date);
|
const toDate = (date?: string | Date): Date | undefined => (typeof date === 'string' ? parseISO(date) : date);
|
||||||
|
|
||||||
|
const isCreationData = (data: ShlinkCreateShortUrlData | ShlinkEditShortUrlData): data is ShlinkCreateShortUrlData =>
|
||||||
|
'shortCodeLength' in data && 'customSlug' in data && 'domain' in data;
|
||||||
|
|
||||||
export const ShortUrlForm = (
|
export const ShortUrlForm = (
|
||||||
TagsSelector: FC<TagsSelectorProps>,
|
TagsSelector: FC<TagsSelectorProps>,
|
||||||
DomainSelector: FC<DomainSelectorProps>,
|
DomainSelector: FC<DomainSelectorProps>,
|
||||||
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => {
|
) => function ShortUrlFormComp<T extends ShlinkCreateShortUrlData | ShlinkEditShortUrlData>(
|
||||||
|
{ mode, saving, onSave, initialState }: ShortUrlFormProps<T>,
|
||||||
|
) {
|
||||||
const [shortUrlData, setShortUrlData] = useState(initialState);
|
const [shortUrlData, setShortUrlData] = useState(initialState);
|
||||||
const reset = () => setShortUrlData(initialState);
|
const reset = () => setShortUrlData(initialState);
|
||||||
const supportsDeviceLongUrls = useFeature('deviceLongUrls', selectedServer);
|
const supportsDeviceLongUrls = useFeature('deviceLongUrls');
|
||||||
|
|
||||||
const isEdit = mode === 'edit';
|
const isEdit = mode === 'edit';
|
||||||
|
const isCreation = isCreationData(shortUrlData);
|
||||||
const isBasicMode = mode === 'create-basic';
|
const isBasicMode = mode === 'create-basic';
|
||||||
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
|
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
|
||||||
const setResettableValue = (value: string, initialValue?: any) => {
|
const setResettableValue = (value: string, initialValue?: any) => {
|
||||||
|
@ -84,13 +88,14 @@ export const ShortUrlForm = (
|
||||||
id={id}
|
id={id}
|
||||||
type={type}
|
type={type}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
// @ts-expect-error FIXME Make sure id is a key from T
|
||||||
value={shortUrlData[id] ?? ''}
|
value={shortUrlData[id] ?? ''}
|
||||||
onChange={props.onChange ?? ((e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value }))}
|
onChange={props.onChange ?? ((e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
);
|
);
|
||||||
const renderDeviceLongUrlInput = (id: keyof DeviceLongUrls, placeholder: string, icon: IconProp) => (
|
const renderDeviceLongUrlInput = (id: keyof ShlinkDeviceLongUrls, placeholder: string, icon: IconProp) => (
|
||||||
<IconInput
|
<IconInput
|
||||||
icon={icon}
|
icon={icon}
|
||||||
id={id}
|
id={id}
|
||||||
|
@ -136,8 +141,6 @@ export const ShortUrlForm = (
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const showForwardQueryControl = useFeature('forwardQuery', selectedServer);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form name="shortUrlForm" className="short-url-form" onSubmit={submit}>
|
<form name="shortUrlForm" className="short-url-form" onSubmit={submit}>
|
||||||
{isBasicMode && basicComponents}
|
{isBasicMode && basicComponents}
|
||||||
|
@ -175,7 +178,7 @@ export const ShortUrlForm = (
|
||||||
title: setResettableValue(target.value, initialState.title),
|
title: setResettableValue(target.value, initialState.title),
|
||||||
}),
|
}),
|
||||||
})}
|
})}
|
||||||
{!isEdit && (
|
{!isEdit && isCreation && (
|
||||||
<>
|
<>
|
||||||
<Row>
|
<Row>
|
||||||
<div className="col-lg-6">
|
<div className="col-lg-6">
|
||||||
|
@ -220,7 +223,7 @@ export const ShortUrlForm = (
|
||||||
>
|
>
|
||||||
Validate URL
|
Validate URL
|
||||||
</ShortUrlFormCheckboxGroup>
|
</ShortUrlFormCheckboxGroup>
|
||||||
{!isEdit && (
|
{!isEdit && isCreation && (
|
||||||
<p>
|
<p>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
inline
|
inline
|
||||||
|
@ -244,15 +247,13 @@ export const ShortUrlForm = (
|
||||||
>
|
>
|
||||||
Make it crawlable
|
Make it crawlable
|
||||||
</ShortUrlFormCheckboxGroup>
|
</ShortUrlFormCheckboxGroup>
|
||||||
{showForwardQueryControl && (
|
<ShortUrlFormCheckboxGroup
|
||||||
<ShortUrlFormCheckboxGroup
|
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
|
||||||
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
|
checked={shortUrlData.forwardQuery}
|
||||||
checked={shortUrlData.forwardQuery}
|
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
|
||||||
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
|
>
|
||||||
>
|
Forward query params on redirect
|
||||||
Forward query params on redirect
|
</ShortUrlFormCheckboxGroup>
|
||||||
</ShortUrlFormCheckboxGroup>
|
|
||||||
)}
|
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
|
@ -1,20 +1,18 @@
|
||||||
import { faTag, faTags } from '@fortawesome/free-solid-svg-icons';
|
import { faTag, faTags } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import type { OrderDir } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import { OrderingDropdown, SearchField } from '@shlinkio/shlink-frontend-kit';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { isEmpty, pipe } from 'ramda';
|
import { isEmpty, pipe } from 'ramda';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
|
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
|
||||||
import type { SelectedServer } from '../servers/data';
|
|
||||||
import type { Settings } from '../settings/reducers/settings';
|
|
||||||
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||||
import { formatIsoDate } from '../utils/helpers/date';
|
import { formatIsoDate } from '../utils/dates/helpers/date';
|
||||||
import type { DateRange } from '../utils/helpers/dateIntervals';
|
import type { DateRange } from '../utils/dates/helpers/dateIntervals';
|
||||||
import { datesToDateRange } from '../utils/helpers/dateIntervals';
|
import { datesToDateRange } from '../utils/dates/helpers/dateIntervals';
|
||||||
import { useFeature } from '../utils/helpers/features';
|
import { useFeature } from '../utils/features';
|
||||||
import type { OrderDir } from '../utils/helpers/ordering';
|
import { useSetting } from '../utils/settings';
|
||||||
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
|
||||||
import { SearchField } from '../utils/SearchField';
|
|
||||||
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
|
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
|
||||||
import { SHORT_URLS_ORDERABLE_FIELDS } from './data';
|
import { SHORT_URLS_ORDERABLE_FIELDS } from './data';
|
||||||
import type { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
|
import type { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
|
||||||
|
@ -23,9 +21,7 @@ import { ShortUrlsFilterDropdown } from './helpers/ShortUrlsFilterDropdown';
|
||||||
import './ShortUrlsFilteringBar.scss';
|
import './ShortUrlsFilteringBar.scss';
|
||||||
|
|
||||||
interface ShortUrlsFilteringProps {
|
interface ShortUrlsFilteringProps {
|
||||||
selectedServer: SelectedServer;
|
|
||||||
order: ShortUrlsOrder;
|
order: ShortUrlsOrder;
|
||||||
settings: Settings;
|
|
||||||
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
|
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
shortUrlsAmount?: number;
|
shortUrlsAmount?: number;
|
||||||
|
@ -34,7 +30,7 @@ interface ShortUrlsFilteringProps {
|
||||||
export const ShortUrlsFilteringBar = (
|
export const ShortUrlsFilteringBar = (
|
||||||
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
|
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
|
||||||
TagsSelector: FC<TagsSelectorProps>,
|
TagsSelector: FC<TagsSelectorProps>,
|
||||||
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy, settings }) => {
|
): FC<ShortUrlsFilteringProps> => ({ className, shortUrlsAmount, order, handleOrderBy }) => {
|
||||||
const [filter, toFirstPage] = useShortUrlsQuery();
|
const [filter, toFirstPage] = useShortUrlsQuery();
|
||||||
const {
|
const {
|
||||||
search,
|
search,
|
||||||
|
@ -46,7 +42,8 @@ export const ShortUrlsFilteringBar = (
|
||||||
excludePastValidUntil,
|
excludePastValidUntil,
|
||||||
tagsMode = 'any',
|
tagsMode = 'any',
|
||||||
} = filter;
|
} = filter;
|
||||||
const supportsDisabledFiltering = useFeature('filterDisabledUrls', selectedServer);
|
const supportsDisabledFiltering = useFeature('filterDisabledUrls');
|
||||||
|
const visitsSettings = useSetting('visits');
|
||||||
|
|
||||||
const setDates = pipe(
|
const setDates = pipe(
|
||||||
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
|
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
|
||||||
|
@ -60,7 +57,6 @@ export const ShortUrlsFilteringBar = (
|
||||||
(searchTerm) => toFirstPage({ search: searchTerm }),
|
(searchTerm) => toFirstPage({ search: searchTerm }),
|
||||||
);
|
);
|
||||||
const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags });
|
const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags });
|
||||||
const canChangeTagsMode = useFeature('allTagsFiltering', selectedServer);
|
|
||||||
const toggleTagsMode = pipe(
|
const toggleTagsMode = pipe(
|
||||||
() => (tagsMode === 'any' ? 'all' : 'any'),
|
() => (tagsMode === 'any' ? 'all' : 'any'),
|
||||||
(mode) => toFirstPage({ tagsMode: mode }),
|
(mode) => toFirstPage({ tagsMode: mode }),
|
||||||
|
@ -72,7 +68,7 @@ export const ShortUrlsFilteringBar = (
|
||||||
|
|
||||||
<InputGroup className="mt-3">
|
<InputGroup className="mt-3">
|
||||||
<TagsSelector allowNew={false} placeholder="With tags..." selectedTags={tags} onChange={changeTagSelection} />
|
<TagsSelector allowNew={false} placeholder="With tags..." selectedTags={tags} onChange={changeTagSelection} />
|
||||||
{canChangeTagsMode && tags.length > 1 && (
|
{tags.length > 1 && (
|
||||||
<>
|
<>
|
||||||
<Button outline color="secondary" onClick={toggleTagsMode} id="tagsModeBtn" aria-label="Change tags mode">
|
<Button outline color="secondary" onClick={toggleTagsMode} id="tagsModeBtn" aria-label="Change tags mode">
|
||||||
<FontAwesomeIcon className="short-urls-filtering-bar__tags-icon" icon={tagsMode === 'all' ? faTags : faTag} />
|
<FontAwesomeIcon className="short-urls-filtering-bar__tags-icon" icon={tagsMode === 'all' ? faTags : faTag} />
|
||||||
|
@ -97,7 +93,7 @@ export const ShortUrlsFilteringBar = (
|
||||||
<ShortUrlsFilterDropdown
|
<ShortUrlsFilterDropdown
|
||||||
className="ms-0 ms-md-2 mt-3 mt-md-0"
|
className="ms-0 ms-md-2 mt-3 mt-md-0"
|
||||||
selected={{
|
selected={{
|
||||||
excludeBots: excludeBots ?? settings.visits?.excludeBots,
|
excludeBots: excludeBots ?? visitsSettings?.excludeBots,
|
||||||
excludeMaxVisitsReached,
|
excludeMaxVisitsReached,
|
||||||
excludePastValidUntil,
|
excludePastValidUntil,
|
||||||
}}
|
}}
|
|
@ -1,17 +1,14 @@
|
||||||
|
import type { OrderDir } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import { determineOrderDir } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { pipe } from 'ramda';
|
import { pipe } from 'ramda';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useLocation, useParams } from 'react-router-dom';
|
import { useLocation, useParams } from 'react-router-dom';
|
||||||
import { Card } from 'reactstrap';
|
import { Card } from 'reactstrap';
|
||||||
import type { ShlinkShortUrlsListParams, ShlinkShortUrlsOrder } from '../api/types';
|
import type { ShlinkShortUrlsListParams, ShlinkShortUrlsOrder } from '../api-contract';
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
import type { SelectedServer } from '../servers/data';
|
import { useFeature } from '../utils/features';
|
||||||
import { getServerId } from '../servers/data';
|
import { useSettings } from '../utils/settings';
|
||||||
import type { Settings } from '../settings/reducers/settings';
|
|
||||||
import { DEFAULT_SHORT_URLS_ORDERING } from '../settings/reducers/settings';
|
|
||||||
import { useFeature } from '../utils/helpers/features';
|
|
||||||
import type { OrderDir } from '../utils/helpers/ordering';
|
|
||||||
import { determineOrderDir } from '../utils/helpers/ordering';
|
|
||||||
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||||
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
|
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
|
||||||
import { useShortUrlsQuery } from './helpers/hooks';
|
import { useShortUrlsQuery } from './helpers/hooks';
|
||||||
|
@ -21,20 +18,23 @@ import type { ShortUrlsFilteringBarType } from './ShortUrlsFilteringBar';
|
||||||
import type { ShortUrlsTableType } from './ShortUrlsTable';
|
import type { ShortUrlsTableType } from './ShortUrlsTable';
|
||||||
|
|
||||||
interface ShortUrlsListProps {
|
interface ShortUrlsListProps {
|
||||||
selectedServer: SelectedServer;
|
|
||||||
shortUrlsList: ShortUrlsListState;
|
shortUrlsList: ShortUrlsListState;
|
||||||
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
||||||
settings: Settings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = {
|
||||||
|
field: 'dateCreated',
|
||||||
|
dir: 'DESC',
|
||||||
|
};
|
||||||
|
|
||||||
export const ShortUrlsList = (
|
export const ShortUrlsList = (
|
||||||
ShortUrlsTable: ShortUrlsTableType,
|
ShortUrlsTable: ShortUrlsTableType,
|
||||||
ShortUrlsFilteringBar: ShortUrlsFilteringBarType,
|
ShortUrlsFilteringBar: ShortUrlsFilteringBarType,
|
||||||
) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => {
|
) => boundToMercureHub(({ listShortUrls, shortUrlsList }: ShortUrlsListProps) => {
|
||||||
const serverId = getServerId(selectedServer);
|
|
||||||
const { page } = useParams();
|
const { page } = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [filter, toFirstPage] = useShortUrlsQuery();
|
const [filter, toFirstPage] = useShortUrlsQuery();
|
||||||
|
const settings = useSettings();
|
||||||
const {
|
const {
|
||||||
tags,
|
tags,
|
||||||
search,
|
search,
|
||||||
|
@ -52,7 +52,7 @@ export const ShortUrlsList = (
|
||||||
);
|
);
|
||||||
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
||||||
const doExcludeBots = excludeBots ?? settings.visits?.excludeBots;
|
const doExcludeBots = excludeBots ?? settings.visits?.excludeBots;
|
||||||
const supportsExcludingBots = useFeature('excludeBotsOnShortUrls', selectedServer);
|
const supportsExcludingBots = useFeature('excludeBotsOnShortUrls');
|
||||||
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
|
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
|
||||||
toFirstPage({ orderBy: { field, dir } });
|
toFirstPage({ orderBy: { field, dir } });
|
||||||
setActualOrderBy({ field, dir });
|
setActualOrderBy({ field, dir });
|
||||||
|
@ -101,22 +101,19 @@ export const ShortUrlsList = (
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ShortUrlsFilteringBar
|
<ShortUrlsFilteringBar
|
||||||
selectedServer={selectedServer}
|
|
||||||
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
|
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
|
||||||
order={actualOrderBy}
|
order={actualOrderBy}
|
||||||
handleOrderBy={handleOrderBy}
|
handleOrderBy={handleOrderBy}
|
||||||
settings={settings}
|
|
||||||
className="mb-3"
|
className="mb-3"
|
||||||
/>
|
/>
|
||||||
<Card body className="pb-0">
|
<Card body className="pb-0">
|
||||||
<ShortUrlsTable
|
<ShortUrlsTable
|
||||||
selectedServer={selectedServer}
|
|
||||||
shortUrlsList={shortUrlsList}
|
shortUrlsList={shortUrlsList}
|
||||||
orderByColumn={orderByColumn}
|
orderByColumn={orderByColumn}
|
||||||
renderOrderIcon={renderOrderIcon}
|
renderOrderIcon={renderOrderIcon}
|
||||||
onTagClick={addTag}
|
onTagClick={addTag}
|
||||||
/>
|
/>
|
||||||
<Paginator paginator={pagination} serverId={serverId} currentQueryString={location.search} />
|
<Paginator paginator={pagination} currentQueryString={location.search} />
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
|
@ -1,7 +1,6 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { isEmpty } from 'ramda';
|
import { isEmpty } from 'ramda';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { SelectedServer } from '../servers/data';
|
|
||||||
import type { ShortUrlsOrderableFields } from './data';
|
import type { ShortUrlsOrderableFields } from './data';
|
||||||
import type { ShortUrlsRowType } from './helpers/ShortUrlsRow';
|
import type { ShortUrlsRowType } from './helpers/ShortUrlsRow';
|
||||||
import type { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
import type { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||||
|
@ -11,7 +10,6 @@ interface ShortUrlsTableProps {
|
||||||
orderByColumn?: (column: ShortUrlsOrderableFields) => () => void;
|
orderByColumn?: (column: ShortUrlsOrderableFields) => () => void;
|
||||||
renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode;
|
renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode;
|
||||||
shortUrlsList: ShortUrlsListState;
|
shortUrlsList: ShortUrlsListState;
|
||||||
selectedServer: SelectedServer;
|
|
||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
@ -21,7 +19,6 @@ export const ShortUrlsTable = (ShortUrlsRow: ShortUrlsRowType) => ({
|
||||||
renderOrderIcon,
|
renderOrderIcon,
|
||||||
shortUrlsList,
|
shortUrlsList,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
selectedServer,
|
|
||||||
className,
|
className,
|
||||||
}: ShortUrlsTableProps) => {
|
}: ShortUrlsTableProps) => {
|
||||||
const { error, loading, shortUrls } = shortUrlsList;
|
const { error, loading, shortUrls } = shortUrlsList;
|
||||||
|
@ -52,7 +49,6 @@ export const ShortUrlsTable = (ShortUrlsRow: ShortUrlsRowType) => ({
|
||||||
<ShortUrlsRow
|
<ShortUrlsRow
|
||||||
key={shortUrl.shortUrl}
|
key={shortUrl.shortUrl}
|
||||||
shortUrl={shortUrl}
|
shortUrl={shortUrl}
|
||||||
selectedServer={selectedServer}
|
|
||||||
onTagClick={onTagClick}
|
onTagClick={onTagClick}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
|
@ -1,7 +1,7 @@
|
||||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
|
||||||
import './UseExistingIfFoundInfoIcon.scss';
|
import './UseExistingIfFoundInfoIcon.scss';
|
||||||
|
|
||||||
const InfoModal = ({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) => (
|
const InfoModal = ({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) => (
|
43
shlink-web-component/src/short-urls/data/index.ts
Normal file
43
shlink-web-component/src/short-urls/data/index.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import type { Order } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import type { ShlinkShortUrl } from '../../api-contract';
|
||||||
|
import type { OptionalString } from '../../utils/helpers';
|
||||||
|
|
||||||
|
export interface ShortUrlIdentifier {
|
||||||
|
shortCode: string;
|
||||||
|
domain?: OptionalString;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortUrlModalProps {
|
||||||
|
shortUrl: ShlinkShortUrl;
|
||||||
|
isOpen: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHORT_URLS_ORDERABLE_FIELDS = {
|
||||||
|
dateCreated: 'Created at',
|
||||||
|
shortCode: 'Short URL',
|
||||||
|
longUrl: 'Long URL',
|
||||||
|
title: 'Title',
|
||||||
|
visits: 'Visits',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShortUrlsOrderableFields = keyof typeof SHORT_URLS_ORDERABLE_FIELDS;
|
||||||
|
|
||||||
|
export type ShortUrlsOrder = Order<ShortUrlsOrderableFields>;
|
||||||
|
|
||||||
|
export interface ExportableShortUrl {
|
||||||
|
createdAt: string;
|
||||||
|
title: string;
|
||||||
|
shortUrl: string;
|
||||||
|
domain?: string;
|
||||||
|
shortCode: string;
|
||||||
|
longUrl: string;
|
||||||
|
tags: string;
|
||||||
|
visits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortUrlsFilter {
|
||||||
|
excludeBots?: boolean;
|
||||||
|
excludeMaxVisitsReached?: boolean;
|
||||||
|
excludePastValidUntil?: boolean;
|
||||||
|
}
|
|
@ -1,12 +1,12 @@
|
||||||
import { faClone as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
import { faClone as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { Result } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||||
import { Tooltip } from 'reactstrap';
|
import { Tooltip } from 'reactstrap';
|
||||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
import { ShlinkApiError } from '../../common/ShlinkApiError';
|
||||||
import type { TimeoutToggle } from '../../utils/helpers/hooks';
|
import type { TimeoutToggle } from '../../utils/helpers/hooks';
|
||||||
import { Result } from '../../utils/Result';
|
|
||||||
import type { ShortUrlCreation } from '../reducers/shortUrlCreation';
|
import type { ShortUrlCreation } from '../reducers/shortUrlCreation';
|
||||||
import './CreateShortUrlResult.scss';
|
import './CreateShortUrlResult.scss';
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
|
import { Result } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { pipe } from 'ramda';
|
import { pipe } from 'ramda';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
import { isInvalidDeletionError } from '../../api-contract/utils';
|
||||||
import { isInvalidDeletionError } from '../../api/utils';
|
import { ShlinkApiError } from '../../common/ShlinkApiError';
|
||||||
import { Result } from '../../utils/Result';
|
import { handleEventPreventingDefault } from '../../utils/helpers';
|
||||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
|
||||||
import type { ShortUrlIdentifier, ShortUrlModalProps } from '../data';
|
import type { ShortUrlIdentifier, ShortUrlModalProps } from '../data';
|
||||||
import type { ShortUrlDeletion } from '../reducers/shortUrlDeletion';
|
import type { ShortUrlDeletion } from '../reducers/shortUrlDeletion';
|
||||||
|
|
|
@ -1,39 +1,27 @@
|
||||||
|
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import type { ShlinkApiClient, ShlinkShortUrl } from '../../api-contract';
|
||||||
import type { ReportExporter } from '../../common/services/ReportExporter';
|
import { ExportBtn } from '../../utils/components/ExportBtn';
|
||||||
import type { SelectedServer } from '../../servers/data';
|
import type { ReportExporter } from '../../utils/services/ReportExporter';
|
||||||
import { isServerWithId } from '../../servers/data';
|
|
||||||
import { ExportBtn } from '../../utils/ExportBtn';
|
|
||||||
import { useToggle } from '../../utils/helpers/hooks';
|
|
||||||
import type { ShortUrl } from '../data';
|
|
||||||
import { useShortUrlsQuery } from './hooks';
|
import { useShortUrlsQuery } from './hooks';
|
||||||
|
|
||||||
export interface ExportShortUrlsBtnProps {
|
export interface ExportShortUrlsBtnProps {
|
||||||
amount?: number;
|
amount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExportShortUrlsBtnConnectProps extends ExportShortUrlsBtnProps {
|
|
||||||
selectedServer: SelectedServer;
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemsPerPage = 20;
|
const itemsPerPage = 20;
|
||||||
|
|
||||||
export const ExportShortUrlsBtn = (
|
export const ExportShortUrlsBtn = (
|
||||||
buildShlinkApiClient: ShlinkApiClientBuilder,
|
apiClientFactory: () => ShlinkApiClient,
|
||||||
{ exportShortUrls }: ReportExporter,
|
{ exportShortUrls }: ReportExporter,
|
||||||
): FC<ExportShortUrlsBtnConnectProps> => ({ amount = 0, selectedServer }) => {
|
): FC<ExportShortUrlsBtnProps> => ({ amount = 0 }) => {
|
||||||
const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery();
|
const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery();
|
||||||
const [loading,, startLoading, stopLoading] = useToggle();
|
const [loading,, startLoading, stopLoading] = useToggle();
|
||||||
const exportAllUrls = useCallback(async () => {
|
const exportAllUrls = useCallback(async () => {
|
||||||
if (!isServerWithId(selectedServer)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalPages = amount / itemsPerPage;
|
const totalPages = amount / itemsPerPage;
|
||||||
const { listShortUrls } = buildShlinkApiClient(selectedServer);
|
const loadAllUrls = async (page = 1): Promise<ShlinkShortUrl[]> => {
|
||||||
const loadAllUrls = async (page = 1): Promise<ShortUrl[]> => {
|
const { data } = await apiClientFactory().listShortUrls(
|
||||||
const { data } = await listShortUrls(
|
|
||||||
{ page: `${page}`, tags, searchTerm: search, startDate, endDate, orderBy, tagsMode, itemsPerPage },
|
{ page: `${page}`, tags, searchTerm: search, startDate, endDate, orderBy, tagsMode, itemsPerPage },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -64,7 +52,7 @@ export const ExportShortUrlsBtn = (
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
stopLoading();
|
stopLoading();
|
||||||
}, [selectedServer]);
|
}, []);
|
||||||
|
|
||||||
return <ExportBtn loading={loading} className="btn-md-block" amount={amount} onClick={exportAllUrls} />;
|
return <ExportBtn loading={loading} className="btn-md-block" amount={amount} onClick={exportAllUrls} />;
|
||||||
};
|
};
|
|
@ -3,29 +3,22 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { Button, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
|
import { Button, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
|
||||||
import type { ImageDownloader } from '../../common/services/ImageDownloader';
|
import { CopyToClipboardIcon } from '../../utils/components/CopyToClipboardIcon';
|
||||||
import type { SelectedServer } from '../../servers/data';
|
|
||||||
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
|
||||||
import { useFeature } from '../../utils/helpers/features';
|
|
||||||
import type { QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
|
import type { QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
|
||||||
import { buildQrCodeUrl } from '../../utils/helpers/qrCodes';
|
import { buildQrCodeUrl } from '../../utils/helpers/qrCodes';
|
||||||
|
import type { ImageDownloader } from '../../utils/services/ImageDownloader';
|
||||||
import type { ShortUrlModalProps } from '../data';
|
import type { ShortUrlModalProps } from '../data';
|
||||||
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
|
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
|
||||||
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
|
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
|
||||||
import './QrCodeModal.scss';
|
import './QrCodeModal.scss';
|
||||||
|
|
||||||
interface QrCodeModalConnectProps extends ShortUrlModalProps {
|
|
||||||
selectedServer: SelectedServer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
||||||
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps,
|
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen }: ShortUrlModalProps,
|
||||||
) => {
|
) => {
|
||||||
const [size, setSize] = useState(300);
|
const [size, setSize] = useState(300);
|
||||||
const [margin, setMargin] = useState(0);
|
const [margin, setMargin] = useState(0);
|
||||||
const [format, setFormat] = useState<QrCodeFormat>('png');
|
const [format, setFormat] = useState<QrCodeFormat>('png');
|
||||||
const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L');
|
const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L');
|
||||||
const displayDownloadBtn = useFeature('nonRestCors', selectedServer);
|
|
||||||
const qrCodeUrl = useMemo(
|
const qrCodeUrl = useMemo(
|
||||||
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }),
|
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }),
|
||||||
[shortUrl, size, format, margin, errorCorrection],
|
[shortUrl, size, format, margin, errorCorrection],
|
||||||
|
@ -46,7 +39,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<Row>
|
<Row>
|
||||||
<FormGroup className="d-grid col-md-4">
|
<FormGroup className="d-grid col-md-6">
|
||||||
<label>Size: {size}px</label>
|
<label>Size: {size}px</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
|
@ -58,7 +51,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
||||||
onChange={(e) => setSize(Number(e.target.value))}
|
onChange={(e) => setSize(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup className="d-grid col-md-4">
|
<FormGroup className="d-grid col-md-6">
|
||||||
<label htmlFor="marginControl">Margin: {margin}px</label>
|
<label htmlFor="marginControl">Margin: {margin}px</label>
|
||||||
<input
|
<input
|
||||||
id="marginControl"
|
id="marginControl"
|
||||||
|
@ -71,7 +64,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
||||||
onChange={(e) => setMargin(Number(e.target.value))}
|
onChange={(e) => setMargin(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup className="d-grid col-md-4">
|
<FormGroup className="d-grid col-md-6">
|
||||||
<QrFormatDropdown format={format} setFormat={setFormat} />
|
<QrFormatDropdown format={format} setFormat={setFormat} />
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup className="col-md-6">
|
<FormGroup className="col-md-6">
|
||||||
|
@ -84,19 +77,17 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
||||||
<CopyToClipboardIcon text={qrCodeUrl} />
|
<CopyToClipboardIcon text={qrCodeUrl} />
|
||||||
</div>
|
</div>
|
||||||
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />
|
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />
|
||||||
{displayDownloadBtn && (
|
<div className="mt-3">
|
||||||
<div className="mt-3">
|
<Button
|
||||||
<Button
|
block
|
||||||
block
|
color="primary"
|
||||||
color="primary"
|
onClick={() => {
|
||||||
onClick={() => {
|
imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`).catch(() => {});
|
||||||
imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`).catch(() => {});
|
}}
|
||||||
}}
|
>
|
||||||
>
|
Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" />
|
||||||
Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" />
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</Modal>
|
</Modal>
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { FC } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { ShlinkShortUrl } from '../../api-contract';
|
||||||
|
import { useRoutesPrefix } from '../../utils/routesPrefix';
|
||||||
|
import { urlEncodeShortCode } from './index';
|
||||||
|
|
||||||
|
export type LinkSuffix = 'visits' | 'edit';
|
||||||
|
|
||||||
|
export interface ShortUrlDetailLinkProps {
|
||||||
|
shortUrl?: ShlinkShortUrl | null;
|
||||||
|
suffix: LinkSuffix;
|
||||||
|
asLink?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildUrl = (routePrefix: string, { shortCode, domain }: ShlinkShortUrl, suffix: LinkSuffix) => {
|
||||||
|
const query = domain ? `?domain=${domain}` : '';
|
||||||
|
return `${routePrefix}/short-code/${urlEncodeShortCode(shortCode)}/${suffix}${query}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShortUrlDetailLink: FC<ShortUrlDetailLinkProps & Record<string | number, any>> = (
|
||||||
|
{ shortUrl, suffix, asLink, children, ...rest },
|
||||||
|
) => {
|
||||||
|
const routePrefix = useRoutesPrefix();
|
||||||
|
if (!asLink || !shortUrl) {
|
||||||
|
return <span {...rest}>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Link to={buildUrl(routePrefix, shortUrl, suffix)} {...rest}>{children}</Link>;
|
||||||
|
};
|
|
@ -1,6 +1,6 @@
|
||||||
|
import { Checkbox } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { ChangeEvent, FC, PropsWithChildren } from 'react';
|
import type { ChangeEvent, FC, PropsWithChildren } from 'react';
|
||||||
import { Checkbox } from '../../utils/Checkbox';
|
import { InfoTooltip } from '../../utils/components/InfoTooltip';
|
||||||
import { InfoTooltip } from '../../utils/InfoTooltip';
|
|
||||||
|
|
||||||
type ShortUrlFormCheckboxGroupProps = PropsWithChildren<{
|
type ShortUrlFormCheckboxGroupProps = PropsWithChildren<{
|
||||||
checked?: boolean;
|
checked?: boolean;
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue