mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-04-23 15:06:17 +00:00
Move some modules from src to shlink-web-component
This commit is contained in:
@@ -18,9 +18,9 @@ import type {
|
||||
ShlinkVisitsParams } from '../../../shlink-web-component/api-contract';
|
||||
import { isRegularNotFound, parseApiError } from '../../../shlink-web-component/api-contract/utils';
|
||||
import type { ShortUrl, ShortUrlData } from '../../../shlink-web-component/short-urls/data';
|
||||
import { stringifyQuery } from '../../../shlink-web-component/utils/helpers/query';
|
||||
import type { HttpClient } from '../../common/services/HttpClient';
|
||||
import { orderToString } from '../../utils/helpers/ordering';
|
||||
import { stringifyQuery } from '../../utils/helpers/query';
|
||||
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
|
||||
import type { OptionalString } from '../../utils/utils';
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
@import '../utils/base';
|
||||
@import '../utils/mixins/vertical-align';
|
||||
|
||||
.aside-menu {
|
||||
width: $asideMenuWidth;
|
||||
background-color: var(--primary-color);
|
||||
box-shadow: rgb(0 0 0 / .05) 0 8px 15px;
|
||||
position: fixed !important;
|
||||
padding-top: 13px;
|
||||
padding-bottom: 10px;
|
||||
top: $headerHeight;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
z-index: 1010;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 30px 15px 15px;
|
||||
}
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
transition: left 300ms;
|
||||
top: $headerHeight - 3px;
|
||||
box-shadow: -10px 0 50px 11px rgb(0 0 0 / .55);
|
||||
}
|
||||
}
|
||||
|
||||
.aside-menu--hidden {
|
||||
@media (max-width: $smMax) {
|
||||
left: -($asideMenuWidth + 35px);
|
||||
}
|
||||
}
|
||||
|
||||
.aside-menu__nav {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.aside-menu__item {
|
||||
padding: 10px 20px;
|
||||
margin: 0 -15px;
|
||||
text-decoration: none !important;
|
||||
cursor: pointer;
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.aside-menu__item:hover {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.aside-menu__item--selected,
|
||||
.aside-menu__item--selected:hover {
|
||||
color: #ffffff;
|
||||
background-color: var(--brand-color);
|
||||
}
|
||||
|
||||
.aside-menu__item-text {
|
||||
margin-left: 8px;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import {
|
||||
faGlobe as domainsIcon,
|
||||
faHome as overviewIcon,
|
||||
faLink as createIcon,
|
||||
faList as listIcon,
|
||||
faTags as tagsIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
import type { FC } from 'react';
|
||||
import type { NavLinkProps } from 'react-router-dom';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import './AsideMenu.scss';
|
||||
|
||||
export interface AsideMenuProps {
|
||||
routePrefix: string;
|
||||
showOnMobile?: boolean;
|
||||
}
|
||||
|
||||
interface AsideMenuItemProps extends NavLinkProps {
|
||||
to: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
|
||||
<NavLink
|
||||
className={({ isActive }) => classNames('aside-menu__item', className, { 'aside-menu__item--selected': isActive })}
|
||||
to={to}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
export const AsideMenu: FC<AsideMenuProps> = ({ routePrefix, showOnMobile = false }) => {
|
||||
const { pathname } = useLocation();
|
||||
const asideClass = classNames('aside-menu', {
|
||||
'aside-menu--hidden': !showOnMobile,
|
||||
});
|
||||
const buildPath = (suffix: string) => `${routePrefix}${suffix}`;
|
||||
|
||||
return (
|
||||
<aside className={asideClass}>
|
||||
<nav className="nav flex-column aside-menu__nav">
|
||||
<AsideMenuItem to={buildPath('/overview')}>
|
||||
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
|
||||
<span className="aside-menu__item-text">Overview</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem
|
||||
to={buildPath('/list-short-urls/1')}
|
||||
className={classNames({ 'aside-menu__item--selected': pathname.match('/list-short-urls') !== null })}
|
||||
>
|
||||
<FontAwesomeIcon fixedWidth icon={listIcon} />
|
||||
<span className="aside-menu__item-text">List short URLs</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/create-short-url')}>
|
||||
<FontAwesomeIcon fixedWidth icon={createIcon} flip="horizontal" />
|
||||
<span className="aside-menu__item-text">Create short URL</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||
<FontAwesomeIcon fixedWidth icon={tagsIcon} />
|
||||
<span className="aside-menu__item-text">Manage tags</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/manage-domains')}>
|
||||
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
|
||||
<span className="aside-menu__item-text">Manage domains</span>
|
||||
</AsideMenuItem>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { ExportableShortUrl } from '../../../shlink-web-component/short-urls/data';
|
||||
import type { NormalizedVisit } from '../../../shlink-web-component/visits/types';
|
||||
import type { JsonToCsv } from '../../utils/helpers/csvjson';
|
||||
import { saveCsv } from '../../utils/helpers/files';
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ScrollToTop } from '../ScrollToTop';
|
||||
import { ShlinkVersionsContainer } from '../ShlinkVersionsContainer';
|
||||
import { HttpClient } from './HttpClient';
|
||||
import { ImageDownloader } from './ImageDownloader';
|
||||
import { ReportExporter } from './ReportExporter';
|
||||
|
||||
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Services
|
||||
@@ -20,7 +19,6 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
|
||||
bottle.service('HttpClient', HttpClient, 'fetch');
|
||||
bottle.service('ImageDownloader', ImageDownloader, 'HttpClient', 'window');
|
||||
bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv');
|
||||
|
||||
// Components
|
||||
bottle.serviceFactory('ScrollToTop', () => ScrollToTop);
|
||||
|
||||
@@ -7,7 +7,7 @@ export const csvToJson = <T>(csvContent: string) => new Promise<T[]>((resolve) =
|
||||
|
||||
export type CsvToJson = typeof csvToJson;
|
||||
|
||||
const jsonParser = new Parser(); // TODO This accepts options if needed
|
||||
const jsonParser = new Parser(); // This accepts options if needed
|
||||
|
||||
export const jsonToCsv = <T>(data: T[]): string => jsonParser.parse(data);
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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';
|
||||
import { parseQuery } from '../../../shlink-web-component/utils/helpers/query';
|
||||
|
||||
const DEFAULT_DELAY = 2000;
|
||||
|
||||
@@ -35,40 +34,6 @@ export const useToggle = (initialValue = false): ToggleResult => {
|
||||
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);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isEmpty } from 'ramda';
|
||||
import { stringifyQuery } from './query';
|
||||
import { stringifyQuery } from '../../../shlink-web-component/utils/helpers/query';
|
||||
|
||||
export type QrCodeFormat = 'svg' | 'png';
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
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' });
|
||||
@@ -1,59 +0,0 @@
|
||||
import { isNil } from 'ramda';
|
||||
import { rangeOf } from '../utils';
|
||||
import type { LocalStorage } from './LocalStorage';
|
||||
|
||||
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];
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type Bottle from 'bottlejs';
|
||||
import { ColorGenerator } from '../../../shlink-web-component/utils/services/ColorGenerator';
|
||||
import { csvToJson, jsonToCsv } from '../helpers/csvjson';
|
||||
import { useTimeoutToggle } from '../helpers/hooks';
|
||||
import { ColorGenerator } from './ColorGenerator';
|
||||
import { LocalStorage } from './LocalStorage';
|
||||
|
||||
export const provideServices = (bottle: Bottle) => {
|
||||
|
||||
Reference in New Issue
Block a user