mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-16 12:33:48 +00:00
Extract shlink-web-component outside of src folder
This commit is contained in:
45
shlink-web-component/visits/helpers/MapModal.scss
Normal file
45
shlink-web-component/visits/helpers/MapModal.scss
Normal 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;
|
||||
}
|
||||
56
shlink-web-component/visits/helpers/MapModal.tsx
Normal file
56
shlink-web-component/visits/helpers/MapModal.tsx
Normal 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='&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>
|
||||
);
|
||||
4
shlink-web-component/visits/helpers/OpenMapModalBtn.scss
Normal file
4
shlink-web-component/visits/helpers/OpenMapModalBtn.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
.open-map-modal-btn__btn.open-map-modal-btn__btn {
|
||||
padding: 0;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
55
shlink-web-component/visits/helpers/OpenMapModalBtn.tsx
Normal file
55
shlink-web-component/visits/helpers/OpenMapModalBtn.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
48
shlink-web-component/visits/helpers/VisitsFilterDropdown.tsx
Normal file
48
shlink-web-component/visits/helpers/VisitsFilterDropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
67
shlink-web-component/visits/helpers/hooks.ts
Normal file
67
shlink-web-component/visits/helpers/hooks.ts
Normal 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];
|
||||
};
|
||||
Reference in New Issue
Block a user