Merge pull request #853 from acelaya-forks/feature/shlink-web-component

Split reusable content into separated components
This commit is contained in:
Alejandro Celaya 2023-08-07 23:41:40 +02:00 committed by GitHub
commit a6134c6b42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
429 changed files with 2933 additions and 2593 deletions

View file

@ -7,8 +7,8 @@
"license": "MIT",
"scripts": {
"lint": "npm run lint:css && npm run lint:js",
"lint:css": "stylelint src/*.scss src/**/*.scss",
"lint:js": "eslint --ext .js,.ts,.tsx src test",
"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 shlink-web-component shlink-frontend-kit test",
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
"lint:css:fix": "npm run lint:css -- --fix",
"lint:js:fix": "npm run lint:js -- --fix",

View file

@ -2,10 +2,10 @@ import type { ReactNode } from 'react';
import type { CardProps } from 'reactstrap';
import { Card, CardBody, CardHeader } from 'reactstrap';
interface SimpleCardProps extends Omit<CardProps, 'title'> {
export type SimpleCardProps = Omit<CardProps, 'title'> & {
title?: ReactNode;
bodyClassName?: string;
}
};
export const SimpleCard = ({ title, children, bodyClassName, ...rest }: SimpleCardProps) => (
<Card {...rest}>

View file

@ -0,0 +1,3 @@
export * from './Message';
export * from './Result';
export * from './SimpleCard';

View file

@ -1,7 +1,7 @@
import classNames from 'classnames';
import { identity } from 'ramda';
import type { ChangeEvent, FC, PropsWithChildren } from 'react';
import { useDomId } from './helpers/hooks';
import { useDomId } from '../hooks';
export type BooleanControlProps = PropsWithChildren<{
checked?: boolean;
@ -10,9 +10,9 @@ export type BooleanControlProps = PropsWithChildren<{
inline?: boolean;
}>;
interface BooleanControlWithTypeProps extends BooleanControlProps {
type BooleanControlWithTypeProps = BooleanControlProps & {
type: 'switch' | 'checkbox';
}
};
export const BooleanControl: FC<BooleanControlWithTypeProps> = (
{ checked = false, onChange = identity, className, children, type, inline = false },

View file

@ -1,6 +1,6 @@
import type { FC, PropsWithChildren } from 'react';
import type { InputType } from 'reactstrap/types/lib/Input';
import { useDomId } from '../helpers/hooks';
import { useDomId } from '../hooks';
import { LabeledFormGroup } from './LabeledFormGroup';
export type InputFormGroupProps = PropsWithChildren<{

View file

@ -1,4 +1,4 @@
@import '../utils/mixins/vertical-align';
@import '../../../shlink-web-component/src/utils/mixins/vertical-align';
.search-field {
position: relative;

View file

@ -7,13 +7,13 @@ import './SearchField.scss';
const DEFAULT_SEARCH_INTERVAL = 500;
let timer: NodeJS.Timeout | null;
interface SearchFieldProps {
type SearchFieldProps = {
onChange: (value: string) => void;
className?: string;
large?: boolean;
noBorder?: boolean;
initialValue?: string;
}
};
export const SearchField = ({ onChange, className, large = true, noBorder = false, initialValue = '' }: SearchFieldProps) => {
const [searchTerm, setSearchTerm] = useState(initialValue);

View file

@ -0,0 +1,5 @@
export * from './Checkbox';
export * from './ToggleSwitch';
export * from './InputFormGroup';
export * from './LabeledFormGroup';
export * from './SearchField';

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

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

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

View file

@ -1,6 +1,6 @@
/* 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 {
text-align: left;

View file

@ -2,7 +2,7 @@ import classNames from 'classnames';
import type { FC, PropsWithChildren, ReactNode } from 'react';
import { Dropdown, DropdownMenu, DropdownToggle } from 'reactstrap';
import type { DropdownToggleProps } from 'reactstrap/types/lib/DropdownToggle';
import { useToggle } from './helpers/hooks';
import { useToggle } from '../hooks';
import './DropdownBtn.scss';
export type DropdownBtnProps = PropsWithChildren<Omit<DropdownToggleProps, 'caret' | 'size' | 'outline'> & {

View file

@ -1,4 +1,4 @@
@import './base';
@import '../base';
.nav-pills__nav {
position: sticky !important;

View file

@ -0,0 +1,3 @@
export * from './DropdownBtn';
export * from './RowDropdownBtn';
export * from './NavPills';

View file

@ -3,18 +3,18 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import { toPairs } from 'ramda';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import type { Order, OrderDir } from './helpers/ordering';
import { determineOrderDir } from './helpers/ordering';
import type { Order, OrderDir } from './ordering';
import { determineOrderDir } from './ordering';
import './OrderingDropdown.scss';
export interface OrderingDropdownProps<T extends string = string> {
export type OrderingDropdownProps<T extends string = string> = {
items: Record<T, string>;
order: Order<T>;
onChange: (orderField?: T, orderDir?: OrderDir) => void;
isButton?: boolean;
right?: boolean;
prefixed?: boolean;
}
};
export function OrderingDropdown<T extends string = string>(
{ items, order, onChange, isButton = true, right = false, prefixed = true }: OrderingDropdownProps<T>,

View file

@ -0,0 +1,2 @@
export * from './ordering';
export * from './OrderingDropdown';

View file

@ -1,9 +1,9 @@
export type OrderDir = 'ASC' | 'DESC' | undefined;
export interface Order<Fields> {
export type Order<Fields> = {
field?: Fields;
dir?: OrderDir;
}
};
export const determineOrderDir = <T extends string = string>(
currentField: T,
@ -22,7 +22,7 @@ export const determineOrderDir = <T extends string = string>(
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) => {
const greaterThan = dir === 'ASC' ? 1 : -1;
const smallerThan = dir === 'ASC' ? -1 : 1;

View file

@ -12,8 +12,6 @@ export const PRIMARY_DARK_COLOR = '#161b22';
export type Theme = 'dark' | 'light';
export const changeThemeInMarkup = (theme: Theme) =>
document.getElementsByTagName('html')?.[0]?.setAttribute('data-theme', theme);
export const changeThemeInMarkup = (theme: Theme) => document.querySelector('html')?.setAttribute('data-theme', theme);
export const isDarkThemeEnabled = (): boolean =>
document.getElementsByTagName('html')?.[0]?.getAttribute('data-theme') === 'dark';
export const isDarkThemeEnabled = (): boolean => document.querySelector('html')?.getAttribute('data-theme') === 'dark';

View file

@ -1,4 +1,4 @@
@import '../../utils/base';
@import '../base';
.responsive-table__header {
@media (max-width: $responsiveTableBreakpoint) {

View file

@ -1,5 +1,7 @@
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 stringifyQuery = (query: any): string => qs.stringify(query, { arrayFormat: 'brackets' });

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

View file

@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import type { PropsWithChildren } from 'react';
import type { MessageProps } from '../../src/utils/Message';
import { Message } from '../../src/utils/Message';
import type { MessageProps } from '../../src';
import { Message } from '../../src';
describe('<Message />', () => {
const setUp = (props: PropsWithChildren<MessageProps> = {}) => render(<Message {...props} />);

View file

@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import type { ResultProps, ResultType } from '../../src/utils/Result';
import { Result } from '../../src/utils/Result';
import type { ResultProps, ResultType } from '../../src';
import { Result } from '../../src';
describe('<Result />', () => {
const setUp = (props: ResultProps) => render(<Result {...props} />);

View file

@ -1,24 +1,27 @@
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 />', () => {
it('does not render title if not provided', () => {
render(<SimpleCard />);
setUp();
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});
it('renders provided title', () => {
render(<SimpleCard title="Cool title" />);
setUp({ title: 'Cool title' });
expect(screen.getByRole('heading')).toHaveTextContent('Cool title');
});
it('renders children inside body', () => {
render(<SimpleCard>Hello world</SimpleCard>);
setUp({ children: 'Hello world' });
expect(screen.getByText('Hello world')).toBeInTheDocument();
});
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}`);
});
});

View file

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react';
import { Checkbox } from '../../src/utils/Checkbox';
import { Checkbox } from '../../src';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<Checkbox />', () => {

View file

@ -1,7 +1,7 @@
import { screen } from '@testing-library/react';
import type { PropsWithChildren } from 'react';
import type { DropdownBtnProps } from '../../src/utils/DropdownBtn';
import { DropdownBtn } from '../../src/utils/DropdownBtn';
import type { DropdownBtnProps } from '../../src';
import { DropdownBtn } from '../../src';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<DropdownBtn />', () => {

View file

@ -1,7 +1,7 @@
/* eslint-disable no-console */
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { NavPillItem, NavPills } from '../../src/utils/NavPills';
import { NavPillItem, NavPills } from '../../src';
describe('<NavPills />', () => {
let originalError: typeof console.error;

View file

@ -1,7 +1,7 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { DropdownBtnMenuProps } from '../../src/utils/RowDropdownBtn';
import { RowDropdownBtn } from '../../src/utils/RowDropdownBtn';
import type { DropdownBtnMenuProps } from '../../src';
import { RowDropdownBtn } from '../../src';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<RowDropdownBtn />', () => {

View file

@ -1,8 +1,7 @@
import { screen } from '@testing-library/react';
import { values } from 'ramda';
import type { OrderDir } from '../../src/utils/helpers/ordering';
import type { OrderingDropdownProps } from '../../src/utils/OrderingDropdown';
import { OrderingDropdown } from '../../src/utils/OrderingDropdown';
import type { OrderDir, OrderingDropdownProps } from '../../src';
import { OrderingDropdown } from '../../src';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<OrderingDropdown />', () => {

View file

@ -1,5 +1,5 @@
import type { OrderDir } from '../../../src/utils/helpers/ordering';
import { determineOrderDir, orderToString, stringToOrder } from '../../../src/utils/helpers/ordering';
import type { OrderDir } from '../../src';
import { determineOrderDir, orderToString, stringToOrder } from '../../src';
describe('ordering', () => {
describe('determineOrderDir', () => {

View file

@ -1,4 +1,4 @@
import { parseQuery, stringifyQuery } from '../../../src/utils/helpers/query';
import { parseQuery, stringifyQuery } from '../../src/utils';
describe('query', () => {
describe('parseQuery', () => {

View file

@ -1,14 +1,14 @@
@import '../utils/base';
@import '@shlinkio/shlink-frontend-kit/base';
.menu-layout__swipeable {
.shlink-layout__swipeable {
height: 100%;
}
.menu-layout__swipeable-inner {
.shlink-layout__swipeable-inner {
height: 100%;
}
.menu-layout__burger-icon {
.shlink-layout__burger-icon {
display: none;
transition: color 300ms;
position: fixed;
@ -23,11 +23,11 @@
}
}
.menu-layout__burger-icon--active {
.shlink-layout__burger-icon--active {
color: white;
}
.menu-layout__container.menu-layout__container {
.shlink-layout__container.shlink-layout__container {
padding: 20px 0 0;
min-height: 100%;

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

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

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

View file

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

View file

@ -1,10 +1,66 @@
import type { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
import type { Order } from '../../utils/helpers/ordering';
import type { OptionalString } from '../../utils/utils';
import type { Visit } from '../../visits/types';
import type { Order } from '@shlinkio/shlink-frontend-kit';
import type { Nullable, OptionalString } from '../utils/helpers';
import type { Visit } from '../visits/types';
export interface ShlinkDeviceLongUrls {
android?: OptionalString;
ios?: OptionalString;
desktop?: OptionalString;
}
export interface ShlinkShortUrlMeta {
validSince?: string;
validUntil?: string;
maxVisits?: number;
}
export interface ShlinkShortUrl {
shortCode: string;
shortUrl: string;
longUrl: string;
deviceLongUrls?: Required<ShlinkDeviceLongUrls>, // Optional only before Shlink 3.5.0
dateCreated: string;
/** @deprecated */
visitsCount: number; // Deprecated since Shlink 3.4.0
visitsSummary?: ShlinkVisitsSummary; // Optional only before Shlink 3.4.0
meta: Required<Nullable<ShlinkShortUrlMeta>>;
tags: string[];
domain: string | null;
title?: string | null;
crawlable?: boolean;
forwardQuery?: boolean;
}
export interface ShlinkEditShortUrlData {
longUrl?: string;
title?: string | null;
tags?: string[];
deviceLongUrls?: ShlinkDeviceLongUrls;
crawlable?: boolean;
forwardQuery?: boolean;
validSince?: string | null;
validUntil?: string | null;
maxVisits?: number | null;
/** @deprecated */
validateUrl?: boolean;
}
export interface ShlinkCreateShortUrlData extends Omit<ShlinkEditShortUrlData, 'deviceLongUrls'> {
longUrl: string;
customSlug?: string;
shortCodeLength?: number;
domain?: string;
findIfExists?: boolean;
deviceLongUrls?: {
android?: string;
ios?: string;
desktop?: string;
}
}
export interface ShlinkShortUrlsResponse {
data: ShortUrl[];
data: ShlinkShortUrl[];
pagination: ShlinkPaginator;
}
@ -70,7 +126,7 @@ export interface ShlinkVisitsOverview {
}
export interface ShlinkVisitsParams {
domain?: OptionalString;
domain?: string | null;
page?: number;
itemsPerPage?: number;
startDate?: string;
@ -78,13 +134,6 @@ export interface ShlinkVisitsParams {
excludeBots?: boolean;
}
export interface ShlinkShortUrlData extends ShortUrlMeta {
longUrl?: string;
title?: string;
validateUrl?: boolean;
tags?: string[];
}
export interface ShlinkDomainRedirects {
baseUrlRedirect: string | null;
regular404Redirect: string | null;
@ -98,12 +147,12 @@ export interface ShlinkEditDomainRedirects extends Partial<ShlinkDomainRedirects
export interface ShlinkDomain {
domain: string;
isDefault: boolean;
redirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.8
redirects: ShlinkDomainRedirects;
}
export interface ShlinkDomainsResponse {
data: ShlinkDomain[];
defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10
defaultRedirects: ShlinkDomainRedirects;
}
export type TagsFilteringMode = 'all' | 'any';

View file

@ -2,16 +2,11 @@ import type {
InvalidArgumentError,
InvalidShortUrlDeletion,
ProblemDetailsError,
RegularNotFound } from '../types/errors';
RegularNotFound } from './errors';
import {
ErrorTypeV2,
ErrorTypeV3,
} from '../types/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);
} from './errors';
export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError =>
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 =>
(error?.type === ErrorTypeV2.NOT_FOUND || error?.type === ErrorTypeV3.NOT_FOUND) && error?.status === 404;
const isProblemDetails = (e: unknown): e is ProblemDetailsError =>
!!e && typeof e === 'object' && ['type', 'detail', 'title', 'status'].every((prop) => prop in e);
export const parseApiError = (e: unknown): ProblemDetailsError | undefined => (isProblemDetails(e) ? e : undefined);

View file

@ -1,4 +1,4 @@
@import '../utils/base';
@import '@shlinkio/shlink-frontend-kit/base';
@import '../utils/mixins/vertical-align';
.aside-menu {
@ -58,24 +58,6 @@
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 {
margin-left: 8px;
}

View file

@ -3,7 +3,6 @@ import {
faHome as overviewIcon,
faLink as createIcon,
faList as listIcon,
faPen as editIcon,
faTags as tagsIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@ -11,13 +10,10 @@ import classNames from 'classnames';
import type { FC } from 'react';
import type { NavLinkProps } 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';
export interface AsideMenuProps {
selectedServer: SelectedServer;
routePrefix: string;
showOnMobile?: boolean;
}
@ -36,16 +32,12 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
</NavLink>
);
export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
{ selectedServer, showOnMobile = false }: AsideMenuProps,
) => {
const hasId = isServerWithId(selectedServer);
const serverId = hasId ? selectedServer.id : '';
export const AsideMenu: FC<AsideMenuProps> = ({ routePrefix, showOnMobile = false }) => {
const { pathname } = useLocation();
const asideClass = classNames('aside-menu', {
'aside-menu--hidden': !showOnMobile,
});
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
const buildPath = (suffix: string) => `${routePrefix}${suffix}`;
return (
<aside className={asideClass}>
@ -73,17 +65,6 @@ export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
<span className="aside-menu__item-text">Manage domains</span>
</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>
</aside>
);

View file

@ -1,5 +1,5 @@
import type { ProblemDetailsError } from './types/errors';
import { isInvalidArgumentError } from './utils';
import type { ProblemDetailsError } from '../api-contract';
import { isInvalidArgumentError } from '../api-contract/utils';
export interface ShlinkApiErrorProps {
errorData?: ProblemDetailsError;

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

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

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

View file

@ -3,9 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react';
import { useEffect } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import type { ShlinkDomainRedirects } from '../api/types';
import type { SelectedServer } from '../servers/data';
import type { OptionalString } from '../utils/utils';
import type { ShlinkDomainRedirects } from '../api-contract';
import type { Domain } from './data';
import { DomainDropdown } from './helpers/DomainDropdown';
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
@ -16,10 +14,9 @@ interface DomainRowProps {
defaultRedirects?: ShlinkDomainRedirects;
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
checkDomainHealth: (domain: string) => void;
selectedServer: SelectedServer;
}
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
const Nr: FC<{ fallback?: string | null }> = ({ fallback }) => (
<span className="text-muted">
{!fallback && <small>No redirect</small>}
{fallback && <>{fallback} <small>(as fallback)</small></>}
@ -33,7 +30,7 @@ const DefaultDomain: FC = () => (
);
export const DomainRow: FC<DomainRowProps> = (
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer },
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects },
) => {
const { domain: authority, isDefault, redirects, status } = domain;
@ -58,7 +55,7 @@ export const DomainRow: FC<DomainRowProps> = (
<DomainStatusIcon status={status} />
</td>
<td className="responsive-table__cell text-end">
<DomainDropdown domain={domain} editDomainRedirects={editDomainRedirects} selectedServer={selectedServer} />
<DomainDropdown domain={domain} editDomainRedirects={editDomainRedirects} />
</td>
</tr>
);

View file

@ -1,4 +1,4 @@
@import '../utils/base';
@import '@shlinkio/shlink-frontend-kit/base';
@import '../utils/mixins/vertical-align';
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn,

View file

@ -1,11 +1,10 @@
import { faUndo } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { DropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
import { isEmpty, pipe } from 'ramda';
import { useEffect } from 'react';
import type { InputProps } from 'reactstrap';
import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip } from 'reactstrap';
import { DropdownBtn } from '../utils/DropdownBtn';
import { useToggle } from '../utils/helpers/hooks';
import type { DomainsList } from './reducers/domainsList';
import './DomainSelector.scss';

View file

@ -1,11 +1,7 @@
import { Message, Result, SearchField, SimpleCard } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { useEffect } from 'react';
import { ShlinkApiError } from '../api/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 { ShlinkApiError } from '../common/ShlinkApiError';
import { DomainRow } from './DomainRow';
import type { EditDomainRedirects } from './reducers/domainRedirects';
import type { DomainsList } from './reducers/domainsList';
@ -16,13 +12,12 @@ interface ManageDomainsProps {
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
checkDomainHealth: (domain: string) => void;
domainsList: DomainsList;
selectedServer: SelectedServer;
}
const headers = ['', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', ''];
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 resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
@ -59,7 +54,6 @@ export const ManageDomains: FC<ManageDomainsProps> = (
editDomainRedirects={editDomainRedirects}
checkDomainHealth={checkDomainHealth}
defaultRedirects={resolvedDefaultRedirects}
selectedServer={selectedServer}
/>
))}
</tbody>

View file

@ -1,4 +1,4 @@
import type { ShlinkDomain } from '../../api/types';
import type { ShlinkDomain } from '../../api-contract';
export type DomainStatus = 'validating' | 'valid' | 'invalid';

View file

@ -1,13 +1,11 @@
import { faChartPie as pieChartIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { RowDropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { Link } from 'react-router-dom';
import { DropdownItem } from 'reactstrap';
import type { SelectedServer } from '../../servers/data';
import { getServerId } from '../../servers/data';
import { useFeature } from '../../utils/helpers/features';
import { useToggle } from '../../utils/helpers/hooks';
import { RowDropdownBtn } from '../../utils/RowDropdownBtn';
import { useFeature } from '../../utils/features';
import { useRoutesPrefix } from '../../utils/routesPrefix';
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
import type { Domain } from '../data';
import type { EditDomainRedirects } from '../reducers/domainRedirects';
@ -16,27 +14,24 @@ import { EditDomainRedirectsModal } from './EditDomainRedirectsModal';
interface DomainDropdownProps {
domain: Domain;
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 { isDefault } = domain;
const canBeEdited = !isDefault || useFeature('defaultDomainRedirectsEdition', selectedServer);
const withVisits = useFeature('domainVisits', selectedServer);
const serverId = getServerId(selectedServer);
const withVisits = useFeature('domainVisits');
const routesPrefix = useRoutesPrefix();
return (
<RowDropdownBtn>
{withVisits && (
<DropdownItem
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
</DropdownItem>
)}
<DropdownItem disabled={!canBeEdited} onClick={!canBeEdited ? undefined : toggleModal}>
<DropdownItem onClick={toggleModal}>
<FontAwesomeIcon fixedWidth icon={editIcon} /> Edit redirects
</DropdownItem>

View file

@ -4,11 +4,11 @@ import {
faTimes as invalidIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useElementRef } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { ExternalLink } from 'react-external-link';
import { UncontrolledTooltip } from 'reactstrap';
import { useElementRef } from '../../utils/helpers/hooks';
import type { MediaMatcher } from '../../utils/types';
import type { DomainStatus } from '../data';

View file

@ -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 { useState } from 'react';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import type { ShlinkDomain } from '../../api/types';
import type { InputFormGroupProps } from '../../utils/forms/InputFormGroup';
import { InputFormGroup } from '../../utils/forms/InputFormGroup';
import { InfoTooltip } from '../../utils/InfoTooltip';
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
import type { ShlinkDomain } from '../../api-contract';
import { InfoTooltip } from '../../utils/components/InfoTooltip';
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/helpers';
import type { EditDomainRedirects } from '../reducers/domainRedirects';
interface EditDomainRedirectsModalProps {

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

View file

@ -1,12 +1,8 @@
import type { AsyncThunk, SliceCaseReducers } from '@reduxjs/toolkit';
import { createAction, createSlice } from '@reduxjs/toolkit';
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import type { ShlinkDomainRedirects } from '../../api/types';
import type { ProblemDetailsError } from '../../api/types/errors';
import { parseApiError } from '../../api/utils';
import { hasServerData } from '../../servers/data';
import { createAsyncThunk } from '../../utils/helpers/redux';
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
import type { ProblemDetailsError, ShlinkApiClient, ShlinkDomainRedirects } from '../../api-contract';
import { parseApiError } from '../../api-contract/utils';
import { createAsyncThunk } from '../../utils/redux';
import type { Domain, DomainStatus } from '../data';
import type { EditDomainRedirects } from './domainRedirects';
@ -45,12 +41,11 @@ export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, status });
export const domainsListReducerCreator = (
buildShlinkApiClient: ShlinkApiClientBuilder,
apiClientFactory: () => ShlinkApiClient,
editDomainRedirects: AsyncThunk<EditDomainRedirects, any, any>,
) => {
const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (_: void, { getState }): Promise<ListDomains> => {
const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState);
const { data, defaultRedirects } = await shlinkListDomains();
const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (): Promise<ListDomains> => {
const { data, defaultRedirects } = await apiClientFactory().listDomains();
return {
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
@ -60,22 +55,9 @@ export const domainsListReducerCreator = (
const checkDomainHealth = createAsyncThunk(
`${REDUCER_PREFIX}/checkDomainHealth`,
async (domain: string, { getState }): Promise<ValidateDomain> => {
const { selectedServer } = getState();
if (!hasServerData(selectedServer)) {
return { domain, status: 'invalid' };
}
async (domain: string): Promise<ValidateDomain> => {
try {
const { url, ...rest } = selectedServer;
const { health } = buildShlinkApiClient({
...rest,
url: replaceAuthorityFromUri(url, domain),
});
const { status } = await health();
const { status } = await apiClientFactory().health(domain);
return { domain, status: status === 'pass' ? 'valid' : 'invalid' };
} catch (e) {
return { domain, status: 'invalid' };

View file

@ -1,6 +1,6 @@
import type Bottle from 'bottlejs';
import { prop } from 'ramda';
import type { ConnectDecorator } from '../../container/types';
import type { ConnectDecorator } from '../../container';
import { DomainSelector } from '../DomainSelector';
import { ManageDomains } from '../ManageDomains';
import { editDomainRedirects } from '../reducers/domainRedirects';
@ -13,7 +13,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('ManageDomains', () => ManageDomains);
bottle.decorator('ManageDomains', connect(
['domainsList', 'selectedServer'],
['domainsList'],
['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'],
));
@ -21,7 +21,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory(
'domainsListReducerCreator',
domainsListReducerCreator,
'buildShlinkApiClient',
'apiClientFactory',
'editDomainRedirects',
);
bottle.serviceFactory('domainsListReducer', prop('reducer'), 'domainsListReducerCreator');
@ -29,6 +29,6 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Actions
bottle.serviceFactory('listDomains', prop('listDomains'), 'domainsListReducerCreator');
bottle.serviceFactory('filterDomains', prop('filterDomains'), 'domainsListReducerCreator');
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'apiClientFactory');
bottle.serviceFactory('checkDomainHealth', prop('checkDomainHealth'), 'domainsListReducerCreator');
};

View file

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

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

View file

@ -23,6 +23,7 @@ export function boundToMercureHub<T = {}>(
const { interval } = mercureInfo;
const params = useParams();
// Every time mercure info changes, re-bind
useEffect(() => {
const onMessage = (visit: CreateVisit) => (interval ? pendingUpdates.add(visit) : createNewVisits([visit]));
const topics = getTopicsForProps(props, params);

View file

@ -1,7 +1,7 @@
import { createSlice } from '@reduxjs/toolkit';
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import type { ShlinkMercureInfo } from '../../api/types';
import { createAsyncThunk } from '../../utils/helpers/redux';
import type { ShlinkApiClient, ShlinkMercureInfo } from '../../api-contract';
import { createAsyncThunk } from '../../utils/redux';
import type { Settings } from '../../utils/settings';
const REDUCER_PREFIX = 'shlink/mercure';
@ -16,16 +16,15 @@ const initialState: MercureInfo = {
error: false,
};
export const mercureInfoReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => {
export const mercureInfoReducerCreator = (apiClientFactory: () => ShlinkApiClient) => {
const loadMercureInfo = createAsyncThunk(
`${REDUCER_PREFIX}/loadMercureInfo`,
(_: void, { getState }): Promise<ShlinkMercureInfo> => {
const { settings } = getState();
if (!settings.realTimeUpdates.enabled) {
({ realTimeUpdates }: Settings): Promise<ShlinkMercureInfo> => {
if (realTimeUpdates && !realTimeUpdates.enabled) {
throw new Error('Real time updates not enabled');
}
return buildShlinkApiClient(getState).mercureInfo();
return apiClientFactory().mercureInfo();
},
);

View file

@ -4,7 +4,7 @@ import { mercureInfoReducerCreator } from '../reducers/mercureInfo';
export const provideServices = (bottle: Bottle) => {
// Reducer
bottle.serviceFactory('mercureInfoReducerCreator', mercureInfoReducerCreator, 'buildShlinkApiClient');
bottle.serviceFactory('mercureInfoReducerCreator', mercureInfoReducerCreator, 'apiClientFactory');
bottle.serviceFactory('mercureInfoReducer', prop('reducer'), 'mercureInfoReducerCreator');
// Actions

View file

@ -2,20 +2,18 @@ import type { FC } from 'react';
import { useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Card, CardBody, CardHeader, Row } from 'reactstrap';
import type { ShlinkShortUrlsListParams } from '../api/types';
import type { ShlinkShortUrlsListParams } from '../api-contract';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics';
import type { Settings } from '../settings/reducers/settings';
import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
import type { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList';
import type { ShortUrlsTableType } from '../short-urls/ShortUrlsTable';
import type { TagsList } from '../tags/reducers/tagsList';
import { useFeature } from '../utils/helpers/features';
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 { SelectedServer } from './data';
import { getServerId } from './data';
import { HighlightCard } from './helpers/HighlightCard';
import { VisitsHighlightCard } from './helpers/VisitsHighlightCard';
@ -24,10 +22,8 @@ interface OverviewConnectProps {
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
listTags: Function;
tagsList: TagsList;
selectedServer: SelectedServer;
visitsOverview: VisitsOverview;
loadVisitsOverview: Function;
settings: Settings;
}
export const Overview = (
@ -38,17 +34,15 @@ export const Overview = (
listShortUrls,
listTags,
tagsList,
selectedServer,
loadVisitsOverview,
visitsOverview,
settings: { visits },
}: OverviewConnectProps) => {
const { loading, shortUrls } = shortUrlsList;
const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview;
const serverId = getServerId(selectedServer);
const linkToNonOrphanVisits = useFeature('nonOrphanVisits', selectedServer);
const routesPrefix = useRoutesPrefix();
const navigate = useNavigate();
const visits = useSetting('visits');
useEffect(() => {
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">
<VisitsHighlightCard
title="Visits"
link={linkToNonOrphanVisits ? `/server/${serverId}/non-orphan-visits` : undefined}
link={`${routesPrefix}/non-orphan-visits`}
excludeBots={visits?.excludeBots ?? false}
loading={loadingVisits}
visitsSummary={nonOrphanVisits}
@ -71,19 +65,19 @@ export const Overview = (
<div className="col-lg-6 col-xl-3 mb-3">
<VisitsHighlightCard
title="Orphan visits"
link={`/server/${serverId}/orphan-visits`}
link={`${routesPrefix}/orphan-visits`}
excludeBots={visits?.excludeBots ?? false}
loading={loadingVisits}
visitsSummary={orphanVisits}
/>
</div>
<div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}>
<HighlightCard title="Short URLs" link={`${routesPrefix}/list-short-urls/1`}>
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
</HighlightCard>
</div>
<div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Tags" link={`/server/${serverId}/manage-tags`}>
<HighlightCard title="Tags" link={`${routesPrefix}/manage-tags`}>
{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}
</HighlightCard>
</div>
@ -93,7 +87,7 @@ export const Overview = (
<CardHeader>
<span className="d-sm-none">Create a short URL</span>
<h5 className="d-none d-sm-inline">Create a short URL</h5>
<Link className="float-end" to={`/server/${serverId}/create-short-url`}>Advanced options &raquo;</Link>
<Link className="float-end" to={`${routesPrefix}/create-short-url`}>Advanced options &raquo;</Link>
</CardHeader>
<CardBody>
<CreateShortUrl basicMode />
@ -103,14 +97,13 @@ export const Overview = (
<CardHeader>
<span className="d-sm-none">Recently created URLs</span>
<h5 className="d-none d-sm-inline">Recently created URLs</h5>
<Link className="float-end" to={`/server/${serverId}/list-short-urls/1`}>See all &raquo;</Link>
<Link className="float-end" to={`${routesPrefix}/list-short-urls/1`}>See all &raquo;</Link>
</CardHeader>
<CardBody>
<ShortUrlsTable
shortUrlsList={shortUrlsList}
selectedServer={selectedServer}
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>
</Card>

View file

@ -1,4 +1,4 @@
@import '../../utils/base';
@import '@shlinkio/shlink-frontend-kit/base';
.highlight-card.highlight-card {
text-align: center;
@ -11,7 +11,7 @@
position: absolute;
right: 5px;
bottom: 5px;
opacity: 0.1;
opacity: .1;
transform: rotate(-45deg);
}

View file

@ -1,18 +1,18 @@
import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useElementRef } from '@shlinkio/shlink-frontend-kit';
import type { FC, PropsWithChildren, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Card, CardText, CardTitle, UncontrolledTooltip } from 'reactstrap';
import { useElementRef } from '../../utils/helpers/hooks';
import './HighlightCard.scss';
export type HighlightCardProps = PropsWithChildren<{
title: string;
link?: string;
link: string;
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 }) => {
const ref = useElementRef<HTMLElement>();
@ -20,7 +20,7 @@ export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link, t
return (
<>
<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>
<CardText tag="h2">{children}</CardText>
</Card>

View file

@ -14,7 +14,7 @@ export const VisitsHighlightCard: FC<VisitsHighlightCardProps> = ({ loading, exc
<HighlightCard
tooltip={
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
}
{...rest}

View file

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

View file

@ -1,8 +1,8 @@
import type { ShlinkCreateShortUrlData } from '@shlinkio/shlink-web-component/api-contract';
import type { FC } from 'react';
import { useMemo } from 'react';
import type { SelectedServer } from '../servers/data';
import type { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings';
import type { ShortUrlData } from './data';
import type { ShortUrlCreationSettings } from '../utils/settings';
import { useSetting } from '../utils/settings';
import type { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
import type { ShortUrlCreation } from './reducers/shortUrlCreation';
import type { ShortUrlFormProps } from './ShortUrlForm';
@ -12,14 +12,12 @@ export interface CreateShortUrlProps {
}
interface CreateShortUrlConnectProps extends CreateShortUrlProps {
settings: Settings;
shortUrlCreation: ShortUrlCreation;
selectedServer: SelectedServer;
createShortUrl: (data: ShortUrlData) => Promise<void>;
createShortUrl: (data: ShlinkCreateShortUrlData) => Promise<void>;
resetCreateShortUrl: () => void;
}
const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => ({
const getInitialState = (settings?: ShortUrlCreationSettings): ShlinkCreateShortUrlData => ({
longUrl: '',
tags: [],
customSlug: '',
@ -35,16 +33,15 @@ const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => (
});
export const CreateShortUrl = (
ShortUrlForm: FC<ShortUrlFormProps>,
ShortUrlForm: FC<ShortUrlFormProps<ShlinkCreateShortUrlData>>,
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
) => ({
createShortUrl,
shortUrlCreation,
resetCreateShortUrl,
selectedServer,
basicMode = false,
settings: { shortUrlCreation: shortUrlCreationSettings },
}: CreateShortUrlConnectProps) => {
const shortUrlCreationSettings = useSetting('shortUrlCreation');
const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [shortUrlCreationSettings]);
return (
@ -52,9 +49,8 @@ export const CreateShortUrl = (
<ShortUrlForm
initialState={initialState}
saving={shortUrlCreation.saving}
selectedServer={selectedServer}
mode={basicMode ? 'create-basic' : 'create'}
onSave={async (data: ShortUrlData) => {
onSave={async (data) => {
resetCreateShortUrl();
return createShortUrl(data);
}}

View file

@ -1,17 +1,15 @@
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Message, parseQuery, Result } from '@shlinkio/shlink-frontend-kit';
import type { ShlinkEditShortUrlData } from '@shlinkio/shlink-web-component/api-contract';
import type { FC } from 'react';
import { useEffect, useMemo } from 'react';
import { ExternalLink } from 'react-external-link';
import { useLocation, useParams } from 'react-router-dom';
import { Button, Card } from 'reactstrap';
import { ShlinkApiError } from '../api/ShlinkApiError';
import type { SelectedServer } from '../servers/data';
import type { Settings } from '../settings/reducers/settings';
import { ShlinkApiError } from '../common/ShlinkApiError';
import { useGoBack } from '../utils/helpers/hooks';
import { parseQuery } from '../utils/helpers/query';
import { Message } from '../utils/Message';
import { Result } from '../utils/Result';
import { useSetting } from '../utils/settings';
import type { ShortUrlIdentifier } from './data';
import { shortUrlDataFromShortUrl, urlDecodeShortCode } from './helpers';
import type { ShortUrlDetail } from './reducers/shortUrlDetail';
@ -19,17 +17,13 @@ import type { EditShortUrl as EditShortUrlInfo, ShortUrlEdition } from './reduce
import type { ShortUrlFormProps } from './ShortUrlForm';
interface EditShortUrlConnectProps {
settings: Settings;
selectedServer: SelectedServer;
shortUrlDetail: ShortUrlDetail;
shortUrlEdition: ShortUrlEdition;
getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void;
editShortUrl: (editShortUrl: EditShortUrlInfo) => void;
}
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
settings: { shortUrlCreation: shortUrlCreationSettings },
selectedServer,
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps<ShlinkEditShortUrlData>>) => ({
shortUrlDetail,
getShortUrlDetail,
shortUrlEdition,
@ -41,6 +35,7 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
const { loading, error, errorData, shortUrl } = shortUrlDetail;
const { saving, saved, error: savingError, errorData: savingErrorData } = shortUrlEdition;
const { domain } = parseQuery<{ domain?: string }>(search);
const shortUrlCreationSettings = useSetting('shortUrlCreation');
const initialState = useMemo(
() => shortUrlDataFromShortUrl(shortUrl, shortUrlCreationSettings),
[shortUrl, shortUrlCreationSettings],
@ -80,7 +75,6 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
<ShortUrlForm
initialState={initialState}
saving={saving}
selectedServer={selectedServer}
mode="edit"
onSave={async (shortUrlData) => {
if (!shortUrl) {

View file

@ -1,6 +1,6 @@
import { Link } from 'react-router-dom';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import type { ShlinkPaginator } from '../api/types';
import type { ShlinkPaginator } from '../api-contract';
import type {
NumberOrEllipsis } from '../utils/helpers/pagination';
import {
@ -9,17 +9,18 @@ import {
prettifyPageNumber,
progressivePagination,
} from '../utils/helpers/pagination';
import { useRoutesPrefix } from '../utils/routesPrefix';
interface PaginatorProps {
paginator?: ShlinkPaginator;
serverId: string;
currentQueryString?: string;
}
export const Paginator = ({ paginator, serverId, currentQueryString = '' }: PaginatorProps) => {
export const Paginator = ({ paginator, currentQueryString = '' }: PaginatorProps) => {
const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
const routesPrefix = useRoutesPrefix();
const urlForPage = (pageNumber: NumberOrEllipsis) =>
`/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`;
`${routesPrefix}/list-short-urls/${pageNumber}${currentQueryString}`;
if (pagesCount <= 1) {
return <div className="pb-3" />; // Return some space

View file

@ -1,4 +1,4 @@
@import '../utils/base';
@import '@shlinkio/shlink-frontend-kit/base';
.short-url-form p:last-child {
margin-bottom: 0;

View file

@ -1,6 +1,7 @@
import type { IconProp } from '@fortawesome/fontawesome-svg-core';
import { faAndroid, faApple } from '@fortawesome/free-brands-svg-icons';
import { faDesktop } from '@fortawesome/free-solid-svg-icons';
import { Checkbox, SimpleCard } from '@shlinkio/shlink-frontend-kit';
import classNames from 'classnames';
import { parseISO } from 'date-fns';
import { isEmpty, pipe, replace, trim } from 'ramda';
@ -8,18 +9,15 @@ import type { ChangeEvent, FC } from 'react';
import { useEffect, useState } from 'react';
import { Button, FormGroup, Input, Row } from 'reactstrap';
import type { InputType } from 'reactstrap/types/lib/Input';
import type { ShlinkCreateShortUrlData, ShlinkDeviceLongUrls, ShlinkEditShortUrlData } from '../api-contract';
import type { DomainSelectorProps } from '../domains/DomainSelector';
import type { SelectedServer } from '../servers/data';
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 { DateTimeInput } from '../utils/dates/DateTimeInput';
import { formatIsoDate } from '../utils/helpers/date';
import { useFeature } from '../utils/helpers/features';
import { IconInput } from '../utils/IconInput';
import { SimpleCard } from '../utils/SimpleCard';
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
import type { DeviceLongUrls, ShortUrlData } from './data';
import { formatIsoDate } from '../utils/dates/helpers/date';
import { useFeature } from '../utils/features';
import { handleEventPreventingDefault, hasValue } from '../utils/helpers';
import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup';
import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon';
import './ShortUrlForm.scss';
@ -29,26 +27,32 @@ export type Mode = 'create' | 'create-basic' | 'edit';
type DateFields = 'validSince' | 'validUntil';
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;
saving: boolean;
initialState: ShortUrlData;
onSave: (shortUrlData: ShortUrlData) => Promise<unknown>;
selectedServer: SelectedServer;
initialState: T;
onSave: (shortUrlData: T) => Promise<unknown>;
}
const normalizeTag = pipe(trim, replace(/ /g, '-'));
const toDate = (date?: string | Date): Date | undefined => (typeof date === 'string' ? parseISO(date) : date);
const isCreationData = (data: ShlinkCreateShortUrlData | ShlinkEditShortUrlData): data is ShlinkCreateShortUrlData =>
'shortCodeLength' in data && 'customSlug' in data && 'domain' in data;
export const ShortUrlForm = (
TagsSelector: FC<TagsSelectorProps>,
DomainSelector: FC<DomainSelectorProps>,
): 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 reset = () => setShortUrlData(initialState);
const supportsDeviceLongUrls = useFeature('deviceLongUrls', selectedServer);
const supportsDeviceLongUrls = useFeature('deviceLongUrls');
const isEdit = mode === 'edit';
const isCreation = isCreationData(shortUrlData);
const isBasicMode = mode === 'create-basic';
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
const setResettableValue = (value: string, initialValue?: any) => {
@ -84,13 +88,14 @@ export const ShortUrlForm = (
id={id}
type={type}
placeholder={placeholder}
// @ts-expect-error FIXME Make sure id is a key from T
value={shortUrlData[id] ?? ''}
onChange={props.onChange ?? ((e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value }))}
{...props}
/>
</FormGroup>
);
const renderDeviceLongUrlInput = (id: keyof DeviceLongUrls, placeholder: string, icon: IconProp) => (
const renderDeviceLongUrlInput = (id: keyof ShlinkDeviceLongUrls, placeholder: string, icon: IconProp) => (
<IconInput
icon={icon}
id={id}
@ -136,8 +141,6 @@ export const ShortUrlForm = (
</>
);
const showForwardQueryControl = useFeature('forwardQuery', selectedServer);
return (
<form name="shortUrlForm" className="short-url-form" onSubmit={submit}>
{isBasicMode && basicComponents}
@ -175,7 +178,7 @@ export const ShortUrlForm = (
title: setResettableValue(target.value, initialState.title),
}),
})}
{!isEdit && (
{!isEdit && isCreation && (
<>
<Row>
<div className="col-lg-6">
@ -220,7 +223,7 @@ export const ShortUrlForm = (
>
Validate URL
</ShortUrlFormCheckboxGroup>
{!isEdit && (
{!isEdit && isCreation && (
<p>
<Checkbox
inline
@ -244,15 +247,13 @@ export const ShortUrlForm = (
>
Make it crawlable
</ShortUrlFormCheckboxGroup>
{showForwardQueryControl && (
<ShortUrlFormCheckboxGroup
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
checked={shortUrlData.forwardQuery}
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
>
Forward query params on redirect
</ShortUrlFormCheckboxGroup>
)}
<ShortUrlFormCheckboxGroup
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
checked={shortUrlData.forwardQuery}
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
>
Forward query params on redirect
</ShortUrlFormCheckboxGroup>
</SimpleCard>
</div>
</Row>

View file

@ -1,20 +1,18 @@
import { faTag, faTags } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { OrderDir } from '@shlinkio/shlink-frontend-kit';
import { OrderingDropdown, SearchField } from '@shlinkio/shlink-frontend-kit';
import classNames from 'classnames';
import { isEmpty, pipe } from 'ramda';
import type { FC } from 'react';
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
import type { SelectedServer } from '../servers/data';
import type { Settings } from '../settings/reducers/settings';
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
import { formatIsoDate } from '../utils/helpers/date';
import type { DateRange } from '../utils/helpers/dateIntervals';
import { datesToDateRange } from '../utils/helpers/dateIntervals';
import { useFeature } from '../utils/helpers/features';
import type { OrderDir } from '../utils/helpers/ordering';
import { OrderingDropdown } from '../utils/OrderingDropdown';
import { SearchField } from '../utils/SearchField';
import { formatIsoDate } from '../utils/dates/helpers/date';
import type { DateRange } from '../utils/dates/helpers/dateIntervals';
import { datesToDateRange } from '../utils/dates/helpers/dateIntervals';
import { useFeature } from '../utils/features';
import { useSetting } from '../utils/settings';
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { SHORT_URLS_ORDERABLE_FIELDS } from './data';
import type { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
@ -23,9 +21,7 @@ import { ShortUrlsFilterDropdown } from './helpers/ShortUrlsFilterDropdown';
import './ShortUrlsFilteringBar.scss';
interface ShortUrlsFilteringProps {
selectedServer: SelectedServer;
order: ShortUrlsOrder;
settings: Settings;
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
className?: string;
shortUrlsAmount?: number;
@ -34,7 +30,7 @@ interface ShortUrlsFilteringProps {
export const ShortUrlsFilteringBar = (
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
TagsSelector: FC<TagsSelectorProps>,
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy, settings }) => {
): FC<ShortUrlsFilteringProps> => ({ className, shortUrlsAmount, order, handleOrderBy }) => {
const [filter, toFirstPage] = useShortUrlsQuery();
const {
search,
@ -46,7 +42,8 @@ export const ShortUrlsFilteringBar = (
excludePastValidUntil,
tagsMode = 'any',
} = filter;
const supportsDisabledFiltering = useFeature('filterDisabledUrls', selectedServer);
const supportsDisabledFiltering = useFeature('filterDisabledUrls');
const visitsSettings = useSetting('visits');
const setDates = pipe(
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
@ -60,7 +57,6 @@ export const ShortUrlsFilteringBar = (
(searchTerm) => toFirstPage({ search: searchTerm }),
);
const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags });
const canChangeTagsMode = useFeature('allTagsFiltering', selectedServer);
const toggleTagsMode = pipe(
() => (tagsMode === 'any' ? 'all' : 'any'),
(mode) => toFirstPage({ tagsMode: mode }),
@ -72,7 +68,7 @@ export const ShortUrlsFilteringBar = (
<InputGroup className="mt-3">
<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">
<FontAwesomeIcon className="short-urls-filtering-bar__tags-icon" icon={tagsMode === 'all' ? faTags : faTag} />
@ -97,7 +93,7 @@ export const ShortUrlsFilteringBar = (
<ShortUrlsFilterDropdown
className="ms-0 ms-md-2 mt-3 mt-md-0"
selected={{
excludeBots: excludeBots ?? settings.visits?.excludeBots,
excludeBots: excludeBots ?? visitsSettings?.excludeBots,
excludeMaxVisitsReached,
excludePastValidUntil,
}}

View file

@ -1,17 +1,14 @@
import type { OrderDir } from '@shlinkio/shlink-frontend-kit';
import { determineOrderDir } from '@shlinkio/shlink-frontend-kit';
import { pipe } from 'ramda';
import { useEffect, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { Card } from 'reactstrap';
import type { ShlinkShortUrlsListParams, ShlinkShortUrlsOrder } from '../api/types';
import type { ShlinkShortUrlsListParams, ShlinkShortUrlsOrder } from '../api-contract';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics';
import type { SelectedServer } from '../servers/data';
import { getServerId } from '../servers/data';
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 { useFeature } from '../utils/features';
import { useSettings } from '../utils/settings';
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { useShortUrlsQuery } from './helpers/hooks';
@ -21,20 +18,23 @@ import type { ShortUrlsFilteringBarType } from './ShortUrlsFilteringBar';
import type { ShortUrlsTableType } from './ShortUrlsTable';
interface ShortUrlsListProps {
selectedServer: SelectedServer;
shortUrlsList: ShortUrlsListState;
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
settings: Settings;
}
const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = {
field: 'dateCreated',
dir: 'DESC',
};
export const ShortUrlsList = (
ShortUrlsTable: ShortUrlsTableType,
ShortUrlsFilteringBar: ShortUrlsFilteringBarType,
) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => {
const serverId = getServerId(selectedServer);
) => boundToMercureHub(({ listShortUrls, shortUrlsList }: ShortUrlsListProps) => {
const { page } = useParams();
const location = useLocation();
const [filter, toFirstPage] = useShortUrlsQuery();
const settings = useSettings();
const {
tags,
search,
@ -52,7 +52,7 @@ export const ShortUrlsList = (
);
const { pagination } = shortUrlsList?.shortUrls ?? {};
const doExcludeBots = excludeBots ?? settings.visits?.excludeBots;
const supportsExcludingBots = useFeature('excludeBotsOnShortUrls', selectedServer);
const supportsExcludingBots = useFeature('excludeBotsOnShortUrls');
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
toFirstPage({ orderBy: { field, dir } });
setActualOrderBy({ field, dir });
@ -101,22 +101,19 @@ export const ShortUrlsList = (
return (
<>
<ShortUrlsFilteringBar
selectedServer={selectedServer}
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
order={actualOrderBy}
handleOrderBy={handleOrderBy}
settings={settings}
className="mb-3"
/>
<Card body className="pb-0">
<ShortUrlsTable
selectedServer={selectedServer}
shortUrlsList={shortUrlsList}
orderByColumn={orderByColumn}
renderOrderIcon={renderOrderIcon}
onTagClick={addTag}
/>
<Paginator paginator={pagination} serverId={serverId} currentQueryString={location.search} />
<Paginator paginator={pagination} currentQueryString={location.search} />
</Card>
</>
);

View file

@ -1,7 +1,6 @@
import classNames from 'classnames';
import { isEmpty } from 'ramda';
import type { ReactNode } from 'react';
import type { SelectedServer } from '../servers/data';
import type { ShortUrlsOrderableFields } from './data';
import type { ShortUrlsRowType } from './helpers/ShortUrlsRow';
import type { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
@ -11,7 +10,6 @@ interface ShortUrlsTableProps {
orderByColumn?: (column: ShortUrlsOrderableFields) => () => void;
renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode;
shortUrlsList: ShortUrlsListState;
selectedServer: SelectedServer;
onTagClick?: (tag: string) => void;
className?: string;
}
@ -21,7 +19,6 @@ export const ShortUrlsTable = (ShortUrlsRow: ShortUrlsRowType) => ({
renderOrderIcon,
shortUrlsList,
onTagClick,
selectedServer,
className,
}: ShortUrlsTableProps) => {
const { error, loading, shortUrls } = shortUrlsList;
@ -52,7 +49,6 @@ export const ShortUrlsTable = (ShortUrlsRow: ShortUrlsRowType) => ({
<ShortUrlsRow
key={shortUrl.shortUrl}
shortUrl={shortUrl}
selectedServer={selectedServer}
onTagClick={onTagClick}
/>
));

View file

@ -1,7 +1,7 @@
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
import { useToggle } from '../utils/helpers/hooks';
import './UseExistingIfFoundInfoIcon.scss';
const InfoModal = ({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) => (

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

View file

@ -1,12 +1,12 @@
import { faClone as copyIcon } from '@fortawesome/free-regular-svg-icons';
import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Result } from '@shlinkio/shlink-frontend-kit';
import { useEffect } from 'react';
import CopyToClipboard from 'react-copy-to-clipboard';
import { Tooltip } from 'reactstrap';
import { ShlinkApiError } from '../../api/ShlinkApiError';
import { ShlinkApiError } from '../../common/ShlinkApiError';
import type { TimeoutToggle } from '../../utils/helpers/hooks';
import { Result } from '../../utils/Result';
import type { ShortUrlCreation } from '../reducers/shortUrlCreation';
import './CreateShortUrlResult.scss';

View file

@ -1,10 +1,10 @@
import { Result } from '@shlinkio/shlink-frontend-kit';
import { pipe } from 'ramda';
import { useEffect, useState } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { ShlinkApiError } from '../../api/ShlinkApiError';
import { isInvalidDeletionError } from '../../api/utils';
import { Result } from '../../utils/Result';
import { handleEventPreventingDefault } from '../../utils/utils';
import { isInvalidDeletionError } from '../../api-contract/utils';
import { ShlinkApiError } from '../../common/ShlinkApiError';
import { handleEventPreventingDefault } from '../../utils/helpers';
import type { ShortUrlIdentifier, ShortUrlModalProps } from '../data';
import type { ShortUrlDeletion } from '../reducers/shortUrlDeletion';

View file

@ -1,39 +1,27 @@
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { useCallback } from 'react';
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import type { ReportExporter } from '../../common/services/ReportExporter';
import type { SelectedServer } from '../../servers/data';
import { isServerWithId } from '../../servers/data';
import { ExportBtn } from '../../utils/ExportBtn';
import { useToggle } from '../../utils/helpers/hooks';
import type { ShortUrl } from '../data';
import type { ShlinkApiClient, ShlinkShortUrl } from '../../api-contract';
import { ExportBtn } from '../../utils/components/ExportBtn';
import type { ReportExporter } from '../../utils/services/ReportExporter';
import { useShortUrlsQuery } from './hooks';
export interface ExportShortUrlsBtnProps {
amount?: number;
}
interface ExportShortUrlsBtnConnectProps extends ExportShortUrlsBtnProps {
selectedServer: SelectedServer;
}
const itemsPerPage = 20;
export const ExportShortUrlsBtn = (
buildShlinkApiClient: ShlinkApiClientBuilder,
apiClientFactory: () => ShlinkApiClient,
{ exportShortUrls }: ReportExporter,
): FC<ExportShortUrlsBtnConnectProps> => ({ amount = 0, selectedServer }) => {
): FC<ExportShortUrlsBtnProps> => ({ amount = 0 }) => {
const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery();
const [loading,, startLoading, stopLoading] = useToggle();
const exportAllUrls = useCallback(async () => {
if (!isServerWithId(selectedServer)) {
return;
}
const totalPages = amount / itemsPerPage;
const { listShortUrls } = buildShlinkApiClient(selectedServer);
const loadAllUrls = async (page = 1): Promise<ShortUrl[]> => {
const { data } = await listShortUrls(
const loadAllUrls = async (page = 1): Promise<ShlinkShortUrl[]> => {
const { data } = await apiClientFactory().listShortUrls(
{ page: `${page}`, tags, searchTerm: search, startDate, endDate, orderBy, tagsMode, itemsPerPage },
);
@ -64,7 +52,7 @@ export const ExportShortUrlsBtn = (
};
}));
stopLoading();
}, [selectedServer]);
}, []);
return <ExportBtn loading={loading} className="btn-md-block" amount={amount} onClick={exportAllUrls} />;
};

View file

@ -3,29 +3,22 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useMemo, useState } from 'react';
import { ExternalLink } from 'react-external-link';
import { Button, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
import type { ImageDownloader } from '../../common/services/ImageDownloader';
import type { SelectedServer } from '../../servers/data';
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
import { useFeature } from '../../utils/helpers/features';
import { CopyToClipboardIcon } from '../../utils/components/CopyToClipboardIcon';
import type { QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
import { buildQrCodeUrl } from '../../utils/helpers/qrCodes';
import type { ImageDownloader } from '../../utils/services/ImageDownloader';
import type { ShortUrlModalProps } from '../data';
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
import './QrCodeModal.scss';
interface QrCodeModalConnectProps extends ShortUrlModalProps {
selectedServer: SelectedServer;
}
export const QrCodeModal = (imageDownloader: ImageDownloader) => (
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps,
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen }: ShortUrlModalProps,
) => {
const [size, setSize] = useState(300);
const [margin, setMargin] = useState(0);
const [format, setFormat] = useState<QrCodeFormat>('png');
const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L');
const displayDownloadBtn = useFeature('nonRestCors', selectedServer);
const qrCodeUrl = useMemo(
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }),
[shortUrl, size, format, margin, errorCorrection],
@ -46,7 +39,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
</ModalHeader>
<ModalBody>
<Row>
<FormGroup className="d-grid col-md-4">
<FormGroup className="d-grid col-md-6">
<label>Size: {size}px</label>
<input
type="range"
@ -58,7 +51,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
onChange={(e) => setSize(Number(e.target.value))}
/>
</FormGroup>
<FormGroup className="d-grid col-md-4">
<FormGroup className="d-grid col-md-6">
<label htmlFor="marginControl">Margin: {margin}px</label>
<input
id="marginControl"
@ -71,7 +64,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
onChange={(e) => setMargin(Number(e.target.value))}
/>
</FormGroup>
<FormGroup className="d-grid col-md-4">
<FormGroup className="d-grid col-md-6">
<QrFormatDropdown format={format} setFormat={setFormat} />
</FormGroup>
<FormGroup className="col-md-6">
@ -84,19 +77,17 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
<CopyToClipboardIcon text={qrCodeUrl} />
</div>
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />
{displayDownloadBtn && (
<div className="mt-3">
<Button
block
color="primary"
onClick={() => {
imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`).catch(() => {});
}}
>
Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" />
</Button>
</div>
)}
<div className="mt-3">
<Button
block
color="primary"
onClick={() => {
imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`).catch(() => {});
}}
>
Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" />
</Button>
</div>
</div>
</ModalBody>
</Modal>

View file

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

View file

@ -1,6 +1,6 @@
import { Checkbox } from '@shlinkio/shlink-frontend-kit';
import type { ChangeEvent, FC, PropsWithChildren } from 'react';
import { Checkbox } from '../../utils/Checkbox';
import { InfoTooltip } from '../../utils/InfoTooltip';
import { InfoTooltip } from '../../utils/components/InfoTooltip';
type ShortUrlFormCheckboxGroupProps = PropsWithChildren<{
checked?: boolean;

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