mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-13 19:13:46 +00:00
Move some modules from src to shlink-web-component
This commit is contained in:
@@ -4,10 +4,10 @@ import classNames from 'classnames';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { AsideMenu } from '../src/common/AsideMenu';
|
||||
import { NotFound } from '../src/common/NotFound';
|
||||
import { useSwipeable, useToggle } from '../src/utils/helpers/hooks';
|
||||
import { AsideMenu } from './common/AsideMenu';
|
||||
import { useFeature } from './utils/features';
|
||||
import { useSwipeable, useToggle } from './utils/helpers/hooks';
|
||||
import { useRoutesPrefix } from './utils/routesPrefix';
|
||||
|
||||
export const Main = (
|
||||
|
||||
63
shlink-web-component/common/AsideMenu.scss
Normal file
63
shlink-web-component/common/AsideMenu.scss
Normal file
@@ -0,0 +1,63 @@
|
||||
@import '../../src/utils/base';
|
||||
@import '../../src/utils/mixins/vertical-align';
|
||||
|
||||
.aside-menu {
|
||||
width: $asideMenuWidth;
|
||||
background-color: var(--primary-color);
|
||||
box-shadow: rgb(0 0 0 / .05) 0 8px 15px;
|
||||
position: fixed !important;
|
||||
padding-top: 13px;
|
||||
padding-bottom: 10px;
|
||||
top: $headerHeight;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
z-index: 1010;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 30px 15px 15px;
|
||||
}
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
transition: left 300ms;
|
||||
top: $headerHeight - 3px;
|
||||
box-shadow: -10px 0 50px 11px rgb(0 0 0 / .55);
|
||||
}
|
||||
}
|
||||
|
||||
.aside-menu--hidden {
|
||||
@media (max-width: $smMax) {
|
||||
left: -($asideMenuWidth + 35px);
|
||||
}
|
||||
}
|
||||
|
||||
.aside-menu__nav {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.aside-menu__item {
|
||||
padding: 10px 20px;
|
||||
margin: 0 -15px;
|
||||
text-decoration: none !important;
|
||||
cursor: pointer;
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.aside-menu__item:hover {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.aside-menu__item--selected,
|
||||
.aside-menu__item--selected:hover {
|
||||
color: #ffffff;
|
||||
background-color: var(--brand-color);
|
||||
}
|
||||
|
||||
.aside-menu__item-text {
|
||||
margin-left: 8px;
|
||||
}
|
||||
71
shlink-web-component/common/AsideMenu.tsx
Normal file
71
shlink-web-component/common/AsideMenu.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
faGlobe as domainsIcon,
|
||||
faHome as overviewIcon,
|
||||
faLink as createIcon,
|
||||
faList as listIcon,
|
||||
faTags as tagsIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
import type { FC } from 'react';
|
||||
import type { NavLinkProps } from 'react-router-dom';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import './AsideMenu.scss';
|
||||
|
||||
export interface AsideMenuProps {
|
||||
routePrefix: string;
|
||||
showOnMobile?: boolean;
|
||||
}
|
||||
|
||||
interface AsideMenuItemProps extends NavLinkProps {
|
||||
to: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
|
||||
<NavLink
|
||||
className={({ isActive }) => classNames('aside-menu__item', className, { 'aside-menu__item--selected': isActive })}
|
||||
to={to}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
export const AsideMenu: FC<AsideMenuProps> = ({ routePrefix, showOnMobile = false }) => {
|
||||
const { pathname } = useLocation();
|
||||
const asideClass = classNames('aside-menu', {
|
||||
'aside-menu--hidden': !showOnMobile,
|
||||
});
|
||||
const buildPath = (suffix: string) => `${routePrefix}${suffix}`;
|
||||
|
||||
return (
|
||||
<aside className={asideClass}>
|
||||
<nav className="nav flex-column aside-menu__nav">
|
||||
<AsideMenuItem to={buildPath('/overview')}>
|
||||
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
|
||||
<span className="aside-menu__item-text">Overview</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem
|
||||
to={buildPath('/list-short-urls/1')}
|
||||
className={classNames({ 'aside-menu__item--selected': pathname.match('/list-short-urls') !== null })}
|
||||
>
|
||||
<FontAwesomeIcon fixedWidth icon={listIcon} />
|
||||
<span className="aside-menu__item-text">List short URLs</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/create-short-url')}>
|
||||
<FontAwesomeIcon fixedWidth icon={createIcon} flip="horizontal" />
|
||||
<span className="aside-menu__item-text">Create short URL</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||
<FontAwesomeIcon fixedWidth icon={tagsIcon} />
|
||||
<span className="aside-menu__item-text">Manage tags</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/manage-domains')}>
|
||||
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
|
||||
<span className="aside-menu__item-text">Manage domains</span>
|
||||
</AsideMenuItem>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,17 @@
|
||||
import type { IContainer } from 'bottlejs';
|
||||
import Bottle from 'bottlejs';
|
||||
import { pick } from 'ramda';
|
||||
import { connect as reduxConnect } from 'react-redux/es/exports';
|
||||
import { connect as reduxConnect } from 'react-redux';
|
||||
import { HttpClient } from '../../src/common/services/HttpClient';
|
||||
import { ImageDownloader } from '../../src/common/services/ImageDownloader';
|
||||
import { ReportExporter } from '../../src/common/services/ReportExporter';
|
||||
import { csvToJson, jsonToCsv } from '../../src/utils/helpers/csvjson';
|
||||
import { useTimeoutToggle } from '../../src/utils/helpers/hooks';
|
||||
import { ColorGenerator } from '../../src/utils/services/ColorGenerator';
|
||||
import { LocalStorage } from '../../src/utils/services/LocalStorage';
|
||||
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 { ReportExporter } from '../utils/services/ReportExporter';
|
||||
import { provideServices as provideVisitsServices } from '../visits/services/provideServices';
|
||||
import { provideServices as provideWebComponentServices } from './provideServices';
|
||||
|
||||
@@ -38,15 +36,16 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
|
||||
actionServiceNames.reduce(mapActionService, {}),
|
||||
);
|
||||
|
||||
provideWebComponentServices(bottle, connect);
|
||||
provideWebComponentServices(bottle);
|
||||
provideShortUrlsServices(bottle, connect);
|
||||
provideTagsServices(bottle, connect);
|
||||
provideVisitsServices(bottle, connect);
|
||||
provideMercureServices(bottle);
|
||||
provideDomainsServices(bottle, connect);
|
||||
provideOverviewServices(bottle, connect);
|
||||
provideUtilsServices(bottle);
|
||||
|
||||
// TODO Check which of these can be moved to shlink-web-component, and which are needed by the app too
|
||||
// FIXME Check which of these can be moved to shlink-web-component, and which are needed by the app too
|
||||
bottle.constant('window', window);
|
||||
bottle.constant('console', console);
|
||||
bottle.constant('fetch', window.fetch.bind(window));
|
||||
@@ -55,13 +54,5 @@ bottle.service('HttpClient', HttpClient, 'fetch');
|
||||
bottle.service('ImageDownloader', ImageDownloader, 'HttpClient', 'window');
|
||||
bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv');
|
||||
|
||||
bottle.constant('localStorage', window.localStorage);
|
||||
bottle.service('Storage', LocalStorage, 'localStorage');
|
||||
bottle.service('ColorGenerator', ColorGenerator, 'Storage');
|
||||
|
||||
bottle.constant('csvToJson', csvToJson);
|
||||
bottle.constant('jsonToCsv', jsonToCsv);
|
||||
|
||||
bottle.constant('setTimeout', window.setTimeout);
|
||||
bottle.constant('clearTimeout', window.clearTimeout);
|
||||
bottle.serviceFactory('useTimeoutToggle', useTimeoutToggle, 'setTimeout', 'clearTimeout');
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEffect } from 'react';
|
||||
import type { InputProps } from 'reactstrap';
|
||||
import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip } from 'reactstrap';
|
||||
import { DropdownBtn } from '../../src/utils/DropdownBtn';
|
||||
import { useToggle } from '../../src/utils/helpers/hooks';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import type { DomainsList } from './reducers/domainsList';
|
||||
import './DomainSelector.scss';
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import type { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { useToggle } from '../../../src/utils/helpers/hooks';
|
||||
import { RowDropdownBtn } from '../../../src/utils/RowDropdownBtn';
|
||||
import { useFeature } from '../../utils/features';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { useRoutesPrefix } from '../../utils/routesPrefix';
|
||||
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
|
||||
import type { Domain } from '../data';
|
||||
|
||||
@@ -7,9 +7,9 @@ import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Button, Card } from 'reactstrap';
|
||||
import { ShlinkApiError } from '../../src/api/ShlinkApiError';
|
||||
import { useGoBack } from '../../src/utils/helpers/hooks';
|
||||
import { parseQuery } from '../../src/utils/helpers/query';
|
||||
import { Message } from '../../src/utils/Message';
|
||||
import { Result } from '../../src/utils/Result';
|
||||
import { parseQuery } from '../utils/helpers/query';
|
||||
import { useSetting } from '../utils/settings';
|
||||
import type { ShortUrlIdentifier } from './data';
|
||||
import { shortUrlDataFromShortUrl, urlDecodeShortCode } from './helpers';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
||||
import { useToggle } from '../../src/utils/helpers/hooks';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import './UseExistingIfFoundInfoIcon.scss';
|
||||
|
||||
const InfoModal = ({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) => (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import type { ReportExporter } from '../../../src/common/services/ReportExporter';
|
||||
import { ExportBtn } from '../../../src/utils/ExportBtn';
|
||||
import { useToggle } from '../../../src/utils/helpers/hooks';
|
||||
import type { ShlinkApiClient } from '../../api-contract';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import type { ReportExporter } from '../../utils/services/ReportExporter';
|
||||
import type { ShortUrl } from '../data';
|
||||
import { useShortUrlsQuery } from './hooks';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ExternalLink } from 'react-external-link';
|
||||
import { CopyToClipboardIcon } from '../../../src/utils/CopyToClipboardIcon';
|
||||
import { Time } from '../../../src/utils/dates/Time';
|
||||
import type { TimeoutToggle } from '../../../src/utils/helpers/hooks';
|
||||
import type { ColorGenerator } from '../../../src/utils/services/ColorGenerator';
|
||||
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||
import { useSetting } from '../../utils/settings';
|
||||
import type { ShortUrl } from '../data';
|
||||
import { useShortUrlsQuery } from './hooks';
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import type { FC } from 'react';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { useToggle } from '../../../src/utils/helpers/hooks';
|
||||
import { RowDropdownBtn } from '../../../src/utils/RowDropdownBtn';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import type { ShortUrl, ShortUrlModalProps } from '../data';
|
||||
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isEmpty } from 'ramda';
|
||||
import type { FC } from 'react';
|
||||
import type { ColorGenerator } from '../../../src/utils/services/ColorGenerator';
|
||||
import { Tag } from '../../tags/helpers/Tag';
|
||||
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||
|
||||
interface TagsProps {
|
||||
tags: string[];
|
||||
|
||||
@@ -2,10 +2,10 @@ import { isEmpty, pipe } from 'ramda';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { orderToString, stringToOrder } from '../../../src/utils/helpers/ordering';
|
||||
import { parseQuery, stringifyQuery } from '../../../src/utils/helpers/query';
|
||||
import type { BooleanString } from '../../../src/utils/utils';
|
||||
import { parseOptionalBooleanToString } from '../../../src/utils/utils';
|
||||
import type { TagsFilteringMode } from '../../api-contract';
|
||||
import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
|
||||
import { useRoutesPrefix } from '../../utils/routesPrefix';
|
||||
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ import type { FC } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { SimplePaginator } from '../../src/common/SimplePaginator';
|
||||
import { useQueryState } from '../../src/utils/helpers/hooks';
|
||||
import { parseQuery } from '../../src/utils/helpers/query';
|
||||
import { SimpleCard } from '../../src/utils/SimpleCard';
|
||||
import { TableOrderIcon } from '../../src/utils/table/TableOrderIcon';
|
||||
import { useQueryState } from '../utils/helpers/hooks';
|
||||
import { parseQuery } from '../utils/helpers/query';
|
||||
import type { TagsListChildrenProps, TagsOrder, TagsOrderableFields } from './data/TagsListChildrenProps';
|
||||
import type { TagsTableRowProps } from './TagsTableRow';
|
||||
import './TagsTable.scss';
|
||||
|
||||
@@ -3,11 +3,11 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import type { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { useToggle } from '../../src/utils/helpers/hooks';
|
||||
import { prettify } from '../../src/utils/helpers/numbers';
|
||||
import { RowDropdownBtn } from '../../src/utils/RowDropdownBtn';
|
||||
import type { ColorGenerator } from '../../src/utils/services/ColorGenerator';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { useRoutesPrefix } from '../utils/routesPrefix';
|
||||
import type { ColorGenerator } from '../utils/services/ColorGenerator';
|
||||
import type { SimplifiedTag, TagModalProps } from './data';
|
||||
import { TagBullet } from './helpers/TagBullet';
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import { useState } from 'react';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import { Button, Input, InputGroup, Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
||||
import { ShlinkApiError } from '../../../src/api/ShlinkApiError';
|
||||
import { useToggle } from '../../../src/utils/helpers/hooks';
|
||||
import { Result } from '../../../src/utils/Result';
|
||||
import type { ColorGenerator } from '../../../src/utils/services/ColorGenerator';
|
||||
import { handleEventPreventingDefault } from '../../../src/utils/utils';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||
import type { TagModalProps } from '../data';
|
||||
import type { EditTag, TagEdition } from '../reducers/tagEdit';
|
||||
import './EditTagModal.scss';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import classNames from 'classnames';
|
||||
import type { FC, MouseEventHandler, PropsWithChildren } from 'react';
|
||||
import type { ColorGenerator } from '../../../src/utils/services/ColorGenerator';
|
||||
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||
import './Tag.scss';
|
||||
|
||||
type TagProps = PropsWithChildren<{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ColorGenerator } from '../../../src/utils/services/ColorGenerator';
|
||||
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||
import './TagBullet.scss';
|
||||
|
||||
interface TagBulletProps {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete';
|
||||
import ReactTags from 'react-tag-autocomplete';
|
||||
import type { ColorGenerator } from '../../../src/utils/services/ColorGenerator';
|
||||
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||
import { useSetting } from '../../utils/settings';
|
||||
import type { TagsList } from '../reducers/tagsList';
|
||||
import { Tag } from './Tag';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||
import { pick } from 'ramda';
|
||||
import type { ColorGenerator } from '../../../src/utils/services/ColorGenerator';
|
||||
import type { ProblemDetailsError, ShlinkApiClient } from '../../api-contract';
|
||||
import { parseApiError } from '../../api-contract/utils';
|
||||
import { createAsyncThunk } from '../../utils/redux';
|
||||
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/tagEdit';
|
||||
|
||||
|
||||
96
shlink-web-component/utils/helpers/hooks.ts
Normal file
96
shlink-web-component/utils/helpers/hooks.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { DependencyList, EffectCallback } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useSwipeable as useReactSwipeable } from 'react-swipeable';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { parseQuery, stringifyQuery } from './query';
|
||||
|
||||
const DEFAULT_DELAY = 2000;
|
||||
|
||||
export type TimeoutToggle = (initialValue?: boolean, delay?: number) => [boolean, () => void];
|
||||
|
||||
export const useTimeoutToggle = (
|
||||
setTimeout: (callback: Function, timeout: number) => number,
|
||||
clearTimeout: (timer: number) => void,
|
||||
): TimeoutToggle => (initialValue = false, delay = DEFAULT_DELAY) => {
|
||||
const [flag, setFlag] = useState<boolean>(initialValue);
|
||||
const timeout = useRef<number | undefined>(undefined);
|
||||
const callback = () => {
|
||||
setFlag(!initialValue);
|
||||
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
||||
timeout.current = setTimeout(() => setFlag(initialValue), delay);
|
||||
};
|
||||
|
||||
return [flag, callback];
|
||||
};
|
||||
|
||||
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 useSwipeable = (showSidebar: () => void, hideSidebar: () => void) => {
|
||||
const swipeMenuIfNoModalExists = (callback: () => void) => (e: any) => {
|
||||
const swippedOnVisitsTable = (e.event.composedPath() as HTMLElement[]).some(
|
||||
({ classList }) => classList?.contains('visits-table'),
|
||||
);
|
||||
|
||||
if (swippedOnVisitsTable || document.querySelector('.modal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
};
|
||||
|
||||
return useReactSwipeable({
|
||||
delta: 40,
|
||||
onSwipedLeft: swipeMenuIfNoModalExists(hideSidebar),
|
||||
onSwipedRight: swipeMenuIfNoModalExists(showSidebar),
|
||||
});
|
||||
};
|
||||
|
||||
export const useQueryState = <T>(paramName: string, initialState: T): [ T, (newValue: T) => void ] => {
|
||||
const [value, setValue] = useState(initialState);
|
||||
const setValueWithLocation = (valueToSet: T) => {
|
||||
const { location, history } = window;
|
||||
const query = parseQuery<any>(location.search);
|
||||
|
||||
query[paramName] = valueToSet;
|
||||
history.pushState(null, '', `${location.pathname}?${stringifyQuery(query)}`);
|
||||
setValue(valueToSet);
|
||||
};
|
||||
|
||||
return [value, setValueWithLocation];
|
||||
};
|
||||
|
||||
export const useEffectExceptFirstTime = (callback: EffectCallback, deps: DependencyList): void => {
|
||||
const isFirstLoad = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
!isFirstLoad.current && callback();
|
||||
isFirstLoad.current = false;
|
||||
}, deps);
|
||||
};
|
||||
|
||||
export const useGoBack = () => {
|
||||
const navigate = useNavigate();
|
||||
return () => navigate(-1);
|
||||
};
|
||||
|
||||
export const useParsedQuery = <T>(): T => {
|
||||
const { search } = useLocation();
|
||||
return parseQuery<T>(search);
|
||||
};
|
||||
|
||||
export const useDomId = (): string => {
|
||||
const { current: id } = useRef(`dom-${uuid()}`);
|
||||
return id;
|
||||
};
|
||||
|
||||
export const useElementRef = <T>() => useRef<T | null>(null);
|
||||
5
shlink-web-component/utils/helpers/query.ts
Normal file
5
shlink-web-component/utils/helpers/query.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import qs from 'qs';
|
||||
|
||||
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' });
|
||||
59
shlink-web-component/utils/services/ColorGenerator.ts
Normal file
59
shlink-web-component/utils/services/ColorGenerator.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { isNil } from 'ramda';
|
||||
import type { LocalStorage } from '../../../src/utils/services/LocalStorage';
|
||||
import { rangeOf } from '../../../src/utils/utils';
|
||||
|
||||
const HEX_COLOR_LENGTH = 6;
|
||||
const HEX_DIGITS = '0123456789ABCDEF';
|
||||
const LIGHTNESS_BREAKPOINT = 128;
|
||||
|
||||
const { floor, random, sqrt, round } = Math;
|
||||
const buildRandomColor = () =>
|
||||
`#${rangeOf(HEX_COLOR_LENGTH, () => HEX_DIGITS[floor(random() * HEX_DIGITS.length)]).join('')}`;
|
||||
const normalizeKey = (key: string) => key.toLowerCase().trim();
|
||||
const hexColorToRgbArray = (colorHex: string): number[] =>
|
||||
(colorHex.match(/../g) ?? []).map((hex) => parseInt(hex, 16) || 0);
|
||||
// HSP by Darel Rex Finley https://alienryderflex.com/hsp.html
|
||||
const perceivedLightness = (r = 0, g = 0, b = 0) => round(sqrt(0.299 * r ** 2 + 0.587 * g ** 2 + 0.114 * b ** 2));
|
||||
|
||||
export class ColorGenerator {
|
||||
private readonly colors: Record<string, string>;
|
||||
private readonly lights: Record<string, boolean>;
|
||||
|
||||
public constructor(private readonly storage: LocalStorage) {
|
||||
this.colors = this.storage.get<Record<string, string>>('colors') ?? {};
|
||||
this.lights = {};
|
||||
}
|
||||
|
||||
public readonly getColorForKey = (key: string) => {
|
||||
const normalizedKey = normalizeKey(key);
|
||||
const color = this.colors[normalizedKey];
|
||||
|
||||
// If a color has not been set yet, generate a random one and save it
|
||||
if (!color) {
|
||||
return this.setColorForKey(normalizedKey, buildRandomColor());
|
||||
}
|
||||
|
||||
return color;
|
||||
};
|
||||
|
||||
public readonly setColorForKey = (key: string, color: string) => {
|
||||
const normalizedKey = normalizeKey(key);
|
||||
|
||||
this.colors[normalizedKey] = color;
|
||||
this.storage.set('colors', this.colors);
|
||||
|
||||
return color;
|
||||
};
|
||||
|
||||
public readonly isColorLightForKey = (key: string): boolean => {
|
||||
const colorHex = this.getColorForKey(key).substring(1);
|
||||
|
||||
if (isNil(this.lights[colorHex])) {
|
||||
const rgb = hexColorToRgbArray(colorHex);
|
||||
|
||||
this.lights[colorHex] = perceivedLightness(...rgb) >= LIGHTNESS_BREAKPOINT;
|
||||
}
|
||||
|
||||
return this.lights[colorHex];
|
||||
};
|
||||
}
|
||||
14
shlink-web-component/utils/services/LocalStorage.ts
Normal file
14
shlink-web-component/utils/services/LocalStorage.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
const PREFIX = 'shlink';
|
||||
const buildPath = (path: string) => `${PREFIX}.${path}`;
|
||||
|
||||
export class LocalStorage {
|
||||
public constructor(private readonly localStorage: Storage) {}
|
||||
|
||||
public readonly get = <T>(key: string): T | undefined => {
|
||||
const item = this.localStorage.getItem(buildPath(key));
|
||||
|
||||
return item ? JSON.parse(item) as T : undefined;
|
||||
};
|
||||
|
||||
public readonly set = (key: string, value: any) => this.localStorage.setItem(buildPath(key), JSON.stringify(value));
|
||||
}
|
||||
30
shlink-web-component/utils/services/ReportExporter.ts
Normal file
30
shlink-web-component/utils/services/ReportExporter.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { JsonToCsv } from '../../../src/utils/helpers/csvjson';
|
||||
import { saveCsv } from '../../../src/utils/helpers/files';
|
||||
import type { ExportableShortUrl } from '../../short-urls/data';
|
||||
import type { NormalizedVisit } from '../../visits/types';
|
||||
|
||||
export class ReportExporter {
|
||||
public constructor(private readonly window: Window, private readonly jsonToCsv: JsonToCsv) {
|
||||
}
|
||||
|
||||
public readonly exportVisits = (filename: string, visits: NormalizedVisit[]) => {
|
||||
if (!visits.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.exportCsv(filename, visits);
|
||||
};
|
||||
|
||||
public readonly exportShortUrls = (shortUrls: ExportableShortUrl[]) => {
|
||||
if (!shortUrls.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.exportCsv('short_urls.csv', shortUrls);
|
||||
};
|
||||
|
||||
private readonly exportCsv = (filename: string, rows: object[]) => {
|
||||
const csv = this.jsonToCsv(rows);
|
||||
saveCsv(this.window, csv, filename);
|
||||
};
|
||||
}
|
||||
14
shlink-web-component/utils/services/provideServices.ts
Normal file
14
shlink-web-component/utils/services/provideServices.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type Bottle from 'bottlejs';
|
||||
import { useTimeoutToggle } from '../helpers/hooks';
|
||||
import { ColorGenerator } from './ColorGenerator';
|
||||
import { LocalStorage } from './LocalStorage';
|
||||
|
||||
export function provideServices(bottle: Bottle) {
|
||||
bottle.constant('localStorage', window.localStorage);
|
||||
bottle.service('Storage', LocalStorage, 'localStorage');
|
||||
bottle.service('ColorGenerator', ColorGenerator, 'Storage');
|
||||
|
||||
bottle.constant('setTimeout', window.setTimeout);
|
||||
bottle.constant('clearTimeout', window.clearTimeout);
|
||||
bottle.serviceFactory('useTimeoutToggle', useTimeoutToggle, 'setTimeout', 'clearTimeout');
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type { ReportExporter } from '../../src/common/services/ReportExporter';
|
||||
import { useGoBack } from '../../src/utils/helpers/hooks';
|
||||
import type { ShlinkVisitsParams } from '../api-contract';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import type { ReportExporter } from '../utils/services/ReportExporter';
|
||||
import type { DomainVisits as DomainVisitsState, LoadDomainVisits } from './reducers/domainVisits';
|
||||
import type { NormalizedVisit } from './types';
|
||||
import { toApiParams } from './types/helpers';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReportExporter } from '../../src/common/services/ReportExporter';
|
||||
import { useGoBack } from '../../src/utils/helpers/hooks';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import type { ReportExporter } from '../utils/services/ReportExporter';
|
||||
import type { LoadVisits, VisitsInfo } from './reducers/types';
|
||||
import type { NormalizedVisit, VisitsParams } from './types';
|
||||
import { toApiParams } from './types/helpers';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReportExporter } from '../../src/common/services/ReportExporter';
|
||||
import { useGoBack } from '../../src/utils/helpers/hooks';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import type { ReportExporter } from '../utils/services/ReportExporter';
|
||||
import type { LoadOrphanVisits } from './reducers/orphanVisits';
|
||||
import type { VisitsInfo } from './reducers/types';
|
||||
import type { NormalizedVisit, VisitsParams } from './types';
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import type { ReportExporter } from '../../src/common/services/ReportExporter';
|
||||
import { useGoBack } from '../../src/utils/helpers/hooks';
|
||||
import { parseQuery } from '../../src/utils/helpers/query';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import type { ShortUrlIdentifier } from '../short-urls/data';
|
||||
import { urlDecodeShortCode } from '../short-urls/helpers';
|
||||
import type { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
|
||||
import { parseQuery } from '../utils/helpers/query';
|
||||
import type { ReportExporter } from '../utils/services/ReportExporter';
|
||||
import type { LoadShortUrlVisits, ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
|
||||
import { ShortUrlVisitsHeader } from './ShortUrlVisitsHeader';
|
||||
import type { NormalizedVisit, VisitsParams } from './types';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type { ShlinkVisitsParams } from '../../api/types';
|
||||
import type { ReportExporter } from '../../src/common/services/ReportExporter';
|
||||
import { useGoBack } from '../../src/utils/helpers/hooks';
|
||||
import type { ColorGenerator } from '../../src/utils/services/ColorGenerator';
|
||||
import type { ShlinkVisitsParams } from '../api-contract';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import type { ColorGenerator } from '../utils/services/ColorGenerator';
|
||||
import type { ReportExporter } from '../utils/services/ReportExporter';
|
||||
import type { LoadTagVisits, TagVisits as TagVisitsState } from './reducers/tagVisits';
|
||||
import { TagVisitsHeader } from './TagVisitsHeader';
|
||||
import type { NormalizedVisit } from './types';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ColorGenerator } from '../../src/utils/services/ColorGenerator';
|
||||
import { Tag } from '../tags/helpers/Tag';
|
||||
import type { ColorGenerator } from '../utils/services/ColorGenerator';
|
||||
import type { TagVisits } from './reducers/tagVisits';
|
||||
import { VisitsHeader } from './VisitsHeader';
|
||||
import './ShortUrlVisitsHeader.scss';
|
||||
|
||||
@@ -25,11 +25,11 @@ import {
|
||||
} from 'reactstrap';
|
||||
import { pointerOnHover, renderChartLabel } from '../../../src/utils/helpers/charts';
|
||||
import { STANDARD_DATE_FORMAT } from '../../../src/utils/helpers/date';
|
||||
import { useToggle } from '../../../src/utils/helpers/hooks';
|
||||
import { prettify } from '../../../src/utils/helpers/numbers';
|
||||
import { HIGHLIGHTED_COLOR, MAIN_COLOR } from '../../../src/utils/theme';
|
||||
import { ToggleSwitch } from '../../../src/utils/ToggleSwitch';
|
||||
import { rangeOf } from '../../../src/utils/utils';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import type { NormalizedVisit, Stats } from '../types';
|
||||
import { fillTheGaps } from '../utils';
|
||||
import './LineChartCard.scss';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { faMapMarkedAlt as mapIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useState } from 'react';
|
||||
import { Button, Dropdown, DropdownItem, DropdownMenu, UncontrolledTooltip } from 'reactstrap';
|
||||
import { useDomId, useToggle } from '../../../src/utils/helpers/hooks';
|
||||
import { useDomId, useToggle } from '../../utils/helpers/hooks';
|
||||
import type { CityStats } from '../types';
|
||||
import { MapModal } from './MapModal';
|
||||
import './OpenMapModalBtn.scss';
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { formatIsoDate } from '../../../src/utils/helpers/date';
|
||||
import type { DateRange } from '../../../src/utils/helpers/dateIntervals';
|
||||
import { datesToDateRange } from '../../../src/utils/helpers/dateIntervals';
|
||||
import { parseQuery, stringifyQuery } from '../../../src/utils/helpers/query';
|
||||
import type { BooleanString } from '../../../src/utils/utils';
|
||||
import { parseBooleanToString } from '../../../src/utils/utils';
|
||||
import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
|
||||
import type { OrphanVisitType, VisitsFilter } from '../types';
|
||||
|
||||
interface VisitsQuery {
|
||||
|
||||
Reference in New Issue
Block a user