Skip to content

Commit

Permalink
Merge pull request #454 from acelaya-forks/feature/qr-code-defaults
Browse files Browse the repository at this point in the history
Make QR codes start with default options from server
  • Loading branch information
acelaya authored Oct 7, 2024
2 parents 85fd275 + f59b06a commit b817bae
Show file tree
Hide file tree
Showing 14 changed files with 268 additions and 122 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* [#307](https://github.com/shlinkio/shlink-web-component/issues/307) Add new setting to disable short URL deletions confirmation.
* [#435](https://github.com/shlinkio/shlink-web-component/issues/435) Allow toggling between displaying raw user agent and parsed browser/OS in visits table.
* [#197](https://github.com/shlinkio/shlink-web-component/issues/197) Allow line charts to be expanded to the full size of the viewport, both in individual visits views, and when comparing visits.
* [#382](https://github.com/shlinkio/shlink-web-component/issues/382) Initialize QR code modal with all params unset, so that they fall back to the server defaults. Additionally, allow them to be unset if desired.

### Changed
* Update to `@shlinkio/eslint-config-js-coding-standard` 3.0, and migrate to ESLint flat config.
Expand Down
4 changes: 0 additions & 4 deletions src/short-urls/helpers/QrCodeModal.scss

This file was deleted.

91 changes: 47 additions & 44 deletions src/short-urls/helpers/QrCodeModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useMemo, useState } from 'react';
import type { SyntheticEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { ExternalLink } from 'react-external-link';
import { Button, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
import type { FCWithDeps } from '../../container/utils';
Expand All @@ -10,9 +11,9 @@ import type { QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCode
import { buildQrCodeUrl } from '../../utils/helpers/qrCodes';
import type { ImageDownloader } from '../../utils/services/ImageDownloader';
import type { ShortUrlModalProps } from '../data';
import { QrDimensionControl } from './qr-codes/QrDimensionControl';
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
import './QrCodeModal.scss';

type QrCodeModalDeps = {
ImageDownloader: ImageDownloader
Expand All @@ -22,22 +23,25 @@ const QrCodeModal: FCWithDeps<ShortUrlModalProps, QrCodeModalDeps> = (
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen },
) => {
const { ImageDownloader: imageDownloader } = useDependencies(QrCodeModal);
const [size, setSize] = useState(300);
const [margin, setMargin] = useState(0);
const [format, setFormat] = useState<QrCodeFormat>('png');
const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L');
const [size, setSize] = useState<number>();
const [margin, setMargin] = useState<number>();
const [format, setFormat] = useState<QrCodeFormat>();
const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>();
const qrCodeUrl = useMemo(
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }),
[shortUrl, size, format, margin, errorCorrection],
);
const totalSize = useMemo(() => size + margin, [size, margin]);
const modalSize = useMemo(() => {
if (totalSize < 500) {
return undefined;
}
const [modalSize, setModalSize] = useState<'lg' | 'xl'>();
const onImageLoad = useCallback((e: SyntheticEvent<HTMLImageElement>) => {
const image = e.target as HTMLImageElement;
const { naturalWidth } = image;

return totalSize < 800 ? 'lg' : 'xl';
}, [totalSize]);
if (naturalWidth < 500) {
setModalSize(undefined);
} else {
setModalSize(naturalWidth < 800 ? 'lg' : 'xl');
}
}, []);

return (
<Modal isOpen={isOpen} toggle={toggle} centered size={modalSize}>
Expand All @@ -46,45 +50,44 @@ const QrCodeModal: FCWithDeps<ShortUrlModalProps, QrCodeModalDeps> = (
</ModalHeader>
<ModalBody>
<Row>
<FormGroup className="d-grid col-md-6">
<label htmlFor="sizeControl">Size: {size}px</label>
<input
id="sizeControl"
type="range"
className="form-control-range"
value={size}
step={10}
min={50}
max={1000}
onChange={(e) => setSize(Number(e.target.value))}
/>
</FormGroup>
<FormGroup className="d-grid col-md-6">
<label htmlFor="marginControl">Margin: {margin}px</label>
<input
id="marginControl"
type="range"
className="form-control-range"
value={margin}
step={1}
min={0}
max={100}
onChange={(e) => setMargin(Number(e.target.value))}
/>
</FormGroup>
<FormGroup className="d-grid col-md-6">
<QrFormatDropdown format={format} setFormat={setFormat} />
<QrDimensionControl
className="col-sm-6"
name="size"
value={size}
step={10}
min={50}
max={1000}
initial={300}
onChange={setSize}
/>
<QrDimensionControl
className="col-sm-6"
name="margin"
value={margin}
step={1}
min={0}
max={100}
onChange={setMargin}
/>
<FormGroup className="d-grid col-sm-6">
<QrFormatDropdown format={format} onChange={setFormat} />
</FormGroup>
<FormGroup className="col-md-6">
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} setErrorCorrection={setErrorCorrection} />
<FormGroup className="col-sm-6">
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} onChange={setErrorCorrection} />
</FormGroup>
</Row>
<div className="text-center">
<div className="mb-3">
<ExternalLink href={qrCodeUrl} />
<CopyToClipboardIcon text={qrCodeUrl} />
</div>
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />
<img
src={qrCodeUrl}
alt="QR code"
className="shadow-lg"
style={{ maxWidth: '100%' }}
onLoad={onImageLoad}
/>
<div className="mt-3">
<Button
block
Expand Down
65 changes: 65 additions & 0 deletions src/short-urls/helpers/qr-codes/QrDimensionControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { faArrowRotateLeft } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react';
import { useId } from 'react';
import { Button, FormGroup } from 'reactstrap';

export type QrCodeDimensionControlProps = {
name: string;
value?: number;
step?: number;
min?: number;
max?: number;
initial?: number;
onChange: (newValue?: number) => void;
className?: string;
};

export const QrDimensionControl: FC<QrCodeDimensionControlProps> = (
{ name, value, step, min, max, onChange, className, initial = min },
) => {
const id = useId();

return (
<FormGroup className={className}>
{value === undefined && (
<Button
outline
color="link"
className="text-start fst-italic w-100"
style={{ color: 'var(--input-text-color)', borderColor: 'var(--border-color)' }}
onClick={() => onChange(initial)}
>
Customize {name}
</Button>
)}
{value !== undefined && (
<div className="d-flex gap-3">
<div className="d-flex flex-column flex-grow-1">
<label htmlFor={id} className="text-capitalize">{name}: {value}px</label>
<input
id={id}
type="range"
className="form-control-range"
value={value}
step={step}
min={min}
max={max}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
<Button
aria-label={`Default ${name}`}
title={`Default ${name}`}
outline
color="link"
onClick={() => onChange(undefined)}
style={{ color: 'var(--input-text-color)', borderColor: 'var(--border-color)' }}
>
<FontAwesomeIcon icon={faArrowRotateLeft} />
</Button>
</div>
)}
</FormGroup>
);
};
18 changes: 10 additions & 8 deletions src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,26 @@ import { DropdownItem } from 'reactstrap';
import type { QrErrorCorrection } from '../../../utils/helpers/qrCodes';

interface QrErrorCorrectionDropdownProps {
errorCorrection: QrErrorCorrection;
setErrorCorrection: (errorCorrection: QrErrorCorrection) => void;
errorCorrection?: QrErrorCorrection;
onChange: (errorCorrection?: QrErrorCorrection) => void;
}

export const QrErrorCorrectionDropdown: FC<QrErrorCorrectionDropdownProps> = (
{ errorCorrection, setErrorCorrection },
{ errorCorrection, onChange },
) => (
<DropdownBtn text={`Error correction (${errorCorrection})`}>
<DropdownItem active={errorCorrection === 'L'} onClick={() => setErrorCorrection('L')}>
<DropdownBtn text={errorCorrection ? `Error correction (${errorCorrection})` : <i>Default error correction</i>}>
<DropdownItem active={!errorCorrection} onClick={() => onChange(undefined)}>Default</DropdownItem>
<DropdownItem divider tag="hr" />
<DropdownItem active={errorCorrection === 'L'} onClick={() => onChange('L')}>
<b>L</b>ow
</DropdownItem>
<DropdownItem active={errorCorrection === 'M'} onClick={() => setErrorCorrection('M')}>
<DropdownItem active={errorCorrection === 'M'} onClick={() => onChange('M')}>
<b>M</b>edium
</DropdownItem>
<DropdownItem active={errorCorrection === 'Q'} onClick={() => setErrorCorrection('Q')}>
<DropdownItem active={errorCorrection === 'Q'} onClick={() => onChange('Q')}>
<b>Q</b>uartile
</DropdownItem>
<DropdownItem active={errorCorrection === 'H'} onClick={() => setErrorCorrection('H')}>
<DropdownItem active={errorCorrection === 'H'} onClick={() => onChange('H')}>
<b>H</b>igh
</DropdownItem>
</DropdownBtn>
Expand Down
14 changes: 8 additions & 6 deletions src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { DropdownItem } from 'reactstrap';
import type { QrCodeFormat } from '../../../utils/helpers/qrCodes';

interface QrFormatDropdownProps {
format: QrCodeFormat;
setFormat: (format: QrCodeFormat) => void;
format?: QrCodeFormat;
onChange: (format?: QrCodeFormat) => void;
}

export const QrFormatDropdown: FC<QrFormatDropdownProps> = ({ format, setFormat }) => (
<DropdownBtn text={`Format (${format})`}>
<DropdownItem active={format === 'png'} onClick={() => setFormat('png')}>PNG</DropdownItem>
<DropdownItem active={format === 'svg'} onClick={() => setFormat('svg')}>SVG</DropdownItem>
export const QrFormatDropdown: FC<QrFormatDropdownProps> = ({ format, onChange }) => (
<DropdownBtn text={format ? `Format (${format})` : <i>Default format</i>}>
<DropdownItem active={!format} onClick={() => onChange(undefined)}>Default</DropdownItem>
<DropdownItem divider tag="hr" />
<DropdownItem active={format === 'png'} onClick={() => onChange('png')}>PNG</DropdownItem>
<DropdownItem active={format === 'svg'} onClick={() => onChange('svg')}>SVG</DropdownItem>
</DropdownBtn>
);
15 changes: 6 additions & 9 deletions src/utils/helpers/qrCodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,15 @@ export type QrCodeFormat = 'svg' | 'png';
export type QrErrorCorrection = 'L' | 'M' | 'Q' | 'H';

export interface QrCodeOptions {
size: number;
format: QrCodeFormat;
margin: number;
errorCorrection: QrErrorCorrection;
size?: number;
format?: QrCodeFormat;
margin?: number;
errorCorrection?: QrErrorCorrection;
}

export const buildQrCodeUrl = (shortUrl: string, { margin, ...options }: QrCodeOptions): string => {
export const buildQrCodeUrl = (shortUrl: string, options: QrCodeOptions): string => {
const baseUrl = `${shortUrl}/qr-code`;
const query = stringifyQueryParams({
...options,
margin: margin > 0 ? margin : undefined,
});
const query = stringifyQueryParams({ ...options });

return `${baseUrl}${!query ? '' : `?${query}`}`;
};
10 changes: 4 additions & 6 deletions src/visits/helpers/OpenMapModalBtn.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { faMapMarkedAlt as mapIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useDomId, useToggle } from '@shlinkio/shlink-frontend-kit';
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import { useCallback, useState } from 'react';
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownToggle, UncontrolledTooltip } from 'reactstrap';
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import type { CityStats } from '../types';
import { MapModal } from './MapModal';

Expand All @@ -16,7 +16,6 @@ export const OpenMapModalBtn = ({ modalTitle, activeCities, locations = [] }: Op
const [mapIsOpened, , openMap, closeMap] = useToggle();
const [dropdownIsOpened, toggleDropdown] = useToggle();
const [locationsToShow, setLocationsToShow] = useState<CityStats[]>([]);
const id = useDomId();

const openMapWithCities = useCallback((filterCallback?: (city: CityStats) => boolean) => {
setLocationsToShow(!filterCallback ? locations : locations.filter(filterCallback));
Expand All @@ -29,16 +28,16 @@ export const OpenMapModalBtn = ({ modalTitle, activeCities, locations = [] }: Op
<Button
color="link"
className="p-0"
id={id}
onClick={() => openMapWithCities()}
aria-label="Show in map"
title="Show in map"
>
<FontAwesomeIcon icon={mapIcon} />
</Button>
)}
{activeCities && (
<Dropdown isOpen={dropdownIsOpened} toggle={toggleDropdown}>
<DropdownToggle color="link" className="p-0" id={id}>
<DropdownToggle color="link" className="p-0" title="Show in map">
<FontAwesomeIcon icon={mapIcon} />
</DropdownToggle>
<DropdownMenu end>
Expand All @@ -49,7 +48,6 @@ export const OpenMapModalBtn = ({ modalTitle, activeCities, locations = [] }: Op
</DropdownMenu>
</Dropdown>
)}
<UncontrolledTooltip placement="left" target={id}>Show in map</UncontrolledTooltip>
<MapModal toggle={closeMap} isOpen={mapIsOpened} title={modalTitle} locations={locationsToShow} />
</>
);
Expand Down
Loading

0 comments on commit b817bae

Please sign in to comment.