Extract shlink-web-component outside of src folder

This commit is contained in:
Alejandro Celaya
2023-07-24 20:14:59 +02:00
parent 768fb1992f
commit 3a0cea1268
230 changed files with 485 additions and 524 deletions

View File

@@ -0,0 +1,45 @@
@import '../../../src/utils/base';
@import '../../../src/utils/mixins/fit-with-margin';
.map-modal__modal.map-modal__modal {
@media (min-width: $mdMin) {
$margin: 20px;
@include fit-with-margin($margin);
}
@media (max-width: $smMax) {
$margin: 10px;
@include fit-with-margin($margin);
}
}
.map-modal__modal-content.map-modal__modal-content {
height: 100%;
}
.map-modal__modal-title.map-modal__modal-title {
position: absolute;
width: 100%;
z-index: 1001;
padding: .5rem 1rem 1rem;
margin: 0;
color: #fff;
background: linear-gradient(rgba(0, 0, 0, .5), rgba(0, 0, 0, 0));
}
.map-modal__modal-body.map-modal__modal-body {
padding: 0;
display: flex;
overflow: hidden;
}
.map-modal__modal.map-modal__modal .leaflet-container.leaflet-container {
flex: 1 1 auto;
border-radius: .3rem;
}
.map-modal__modal.map-modal__modal .leaflet-top.leaflet-top .leaflet-control.leaflet-control {
margin-top: 60px;
}

View File

@@ -0,0 +1,56 @@
import { prop } from 'ramda';
import type { FC } from 'react';
import type { MapContainerProps } from 'react-leaflet';
import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet';
import { Modal, ModalBody } from 'reactstrap';
import type { CityStats } from '../types';
import './MapModal.scss';
interface MapModalProps {
toggle: () => void;
isOpen: boolean;
title: string;
locations?: CityStats[];
}
const OpenStreetMapTile: FC = () => (
<TileLayer
attribution='&amp;copy <a href="https://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
);
const calculateMapProps = (locations: CityStats[]): MapContainerProps => {
if (locations.length === 0) {
return {};
}
if (locations.length > 1) {
return { bounds: locations.map(prop('latLong')) };
}
// When there's only one location, an error is thrown if trying to calculate the bounds.
// When that happens, we use "zoom" and "center" as a workaround
const [{ latLong: center }] = locations;
return { zoom: 10, center };
};
export const MapModal = ({ toggle, isOpen, title, locations = [] }: MapModalProps) => (
<Modal toggle={toggle} isOpen={isOpen} className="map-modal__modal" contentClassName="map-modal__modal-content">
<ModalBody className="map-modal__modal-body">
<h3 className="map-modal__modal-title">
{title}
<button type="button" className="btn-close float-end" aria-label="Close" onClick={toggle} />
</h3>
<MapContainer {...calculateMapProps(locations)}>
<OpenStreetMapTile />
{locations.map(({ cityName, latLong, count }, index) => (
<Marker key={index} position={latLong}>
<Popup><b>{count}</b> visit{count > 1 ? 's' : ''} from <b>{cityName}</b></Popup>
</Marker>
))}
</MapContainer>
</ModalBody>
</Modal>
);

View File

@@ -0,0 +1,4 @@
.open-map-modal-btn__btn.open-map-modal-btn__btn {
padding: 0;
margin-right: 1rem;
}

View File

@@ -0,0 +1,55 @@
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 type { CityStats } from '../types';
import { MapModal } from './MapModal';
import './OpenMapModalBtn.scss';
interface OpenMapModalBtnProps {
modalTitle: string;
activeCities?: string[];
locations?: CityStats[];
}
export const OpenMapModalBtn = ({ modalTitle, activeCities, locations = [] }: OpenMapModalBtnProps) => {
const [mapIsOpened, , openMap, closeMap] = useToggle();
const [dropdownIsOpened, toggleDropdown, openDropdown] = useToggle();
const [locationsToShow, setLocationsToShow] = useState<CityStats[]>([]);
const id = useDomId();
const filterLocations = (cities: CityStats[]) => (
!activeCities ? cities : cities.filter(({ cityName }) => activeCities?.includes(cityName))
);
const onClick = () => {
if (!activeCities) {
setLocationsToShow(locations);
openMap();
return;
}
openDropdown();
};
const openMapWithLocations = (filtered: boolean) => () => {
setLocationsToShow(filtered ? filterLocations(locations) : locations);
openMap();
};
return (
<>
<Button color="link" className="open-map-modal-btn__btn" id={id} onClick={onClick}>
<FontAwesomeIcon icon={mapIcon} />
</Button>
<UncontrolledTooltip placement="left" target={id}>Show in map</UncontrolledTooltip>
<Dropdown isOpen={dropdownIsOpened} toggle={toggleDropdown} inNavbar>
<DropdownMenu end>
<DropdownItem onClick={openMapWithLocations(false)}>Show all locations</DropdownItem>
<DropdownItem onClick={openMapWithLocations(true)}>Show locations in current page</DropdownItem>
</DropdownMenu>
</Dropdown>
<MapModal toggle={closeMap} isOpen={mapIsOpened} title={modalTitle} locations={locationsToShow} />
</>
);
};

View File

@@ -0,0 +1,48 @@
import type { DropdownItemProps } from 'reactstrap';
import { DropdownItem } from 'reactstrap';
import { DropdownBtn } from '../../../src/utils/DropdownBtn';
import { hasValue } from '../../../src/utils/utils';
import type { OrphanVisitType, VisitsFilter } from '../types';
interface VisitsFilterDropdownProps {
onChange: (filters: VisitsFilter) => void;
selected?: VisitsFilter;
className?: string;
isOrphanVisits: boolean;
}
export const VisitsFilterDropdown = (
{ onChange, selected = {}, className, isOrphanVisits }: VisitsFilterDropdownProps,
) => {
const { orphanVisitsType, excludeBots = false } = selected;
const propsForOrphanVisitsTypeItem = (type: OrphanVisitType): DropdownItemProps => ({
active: orphanVisitsType === type,
onClick: () => onChange({ ...selected, orphanVisitsType: type === selected?.orphanVisitsType ? undefined : type }),
});
const onBotsClick = () => onChange({ ...selected, excludeBots: !selected?.excludeBots });
return (
<DropdownBtn text="Filters" dropdownClassName={className} inline end minWidth={250}>
<DropdownItem header>Bots:</DropdownItem>
<DropdownItem active={excludeBots} onClick={onBotsClick}>Exclude potential bots</DropdownItem>
{isOrphanVisits && (
<>
<DropdownItem divider />
<DropdownItem header>Orphan visits type:</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('base_url')}>Base URL</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('invalid_short_url')}>Invalid short URL</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('regular_404')}>Regular 404</DropdownItem>
</>
)}
<DropdownItem divider />
<DropdownItem
disabled={!hasValue(selected)}
onClick={() => onChange({ excludeBots: false, orphanVisitsType: undefined })}
>
<i>Clear filters</i>
</DropdownItem>
</DropdownBtn>
);
};

View File

@@ -0,0 +1,67 @@
import type { DeepPartial } from '@reduxjs/toolkit';
import { isEmpty, isNil, mergeDeepRight, pipe } from 'ramda';
import { useMemo } from 'react';
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 type { OrphanVisitType, VisitsFilter } from '../types';
interface VisitsQuery {
startDate?: string;
endDate?: string;
orphanVisitsType?: OrphanVisitType;
excludeBots?: BooleanString;
domain?: string;
}
interface VisitsFiltering {
dateRange?: DateRange;
visitsFilter: VisitsFilter;
}
interface VisitsFilteringAndDomain {
filtering: VisitsFiltering;
domain?: string;
}
type UpdateFiltering = (extra: DeepPartial<VisitsFiltering>) => void;
export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => {
const navigate = useNavigate();
const { search } = useLocation();
const { filtering, domain: theDomain } = useMemo(
pipe(
() => parseQuery<VisitsQuery>(search),
({ startDate, endDate, orphanVisitsType, excludeBots, domain }: VisitsQuery): VisitsFilteringAndDomain => ({
domain,
filtering: {
dateRange: startDate != null || endDate != null ? datesToDateRange(startDate, endDate) : undefined,
visitsFilter: { orphanVisitsType, excludeBots: !isNil(excludeBots) ? excludeBots === 'true' : undefined },
},
}),
),
[search],
);
const updateFiltering = (extra: DeepPartial<VisitsFiltering>) => {
const { dateRange, visitsFilter } = mergeDeepRight(filtering, extra);
const { excludeBots, orphanVisitsType } = visitsFilter;
const query: VisitsQuery = {
startDate: (dateRange?.startDate && formatIsoDate(dateRange.startDate)) || '',
endDate: (dateRange?.endDate && formatIsoDate(dateRange.endDate)) || '',
excludeBots: excludeBots === undefined ? undefined : parseBooleanToString(excludeBots),
orphanVisitsType,
domain: theDomain,
};
const stringifiedQuery = stringifyQuery(query);
const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`;
navigate(queryString, { replace: true, relative: 'route' });
};
return [filtering, updateFiltering];
};