Skip to content

Commit

Permalink
Merge pull request #492 from acelaya-forks/feature/qr-code-color
Browse files Browse the repository at this point in the history
Support color and background color in QR codes modal
  • Loading branch information
acelaya authored Oct 26, 2024
2 parents 538f3a7 + 18c27d1 commit 2799497
Show file tree
Hide file tree
Showing 20 changed files with 352 additions and 149 deletions.
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).

## [Unreleased]
### Added
* [#491](https://github.com/shlinkio/shlink-web-component/issues/491) Add support for colors in QR code configurator.

### Changed
* *Nothing*

### Deprecated
* *Nothing*

### Removed
* *Nothing*

### Fixed
* *Nothing*


## [0.10.1] - 2024-10-19
### Added
* *Nothing*
Expand Down
7 changes: 7 additions & 0 deletions src/short-urls/helpers/QrCodeModal.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@import '../../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';

.qr-code-modal__controls {
@media (min-width: $lgMin) {
width: 16rem;
}
}
86 changes: 42 additions & 44 deletions src/short-urls/helpers/QrCodeModal.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { SyntheticEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { ExternalLink } from 'react-external-link';
import { Button, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import type { FCWithDeps } from '../../container/utils';
import { componentFactory, useDependencies } from '../../container/utils';
import { CopyToClipboardIcon } from '../../utils/components/CopyToClipboardIcon';
import { useFeature } from '../../utils/features';
import type { QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
import { buildQrCodeUrl } from '../../utils/helpers/qrCodes';
import type { ImageDownloader } from '../../utils/services/ImageDownloader';
import type { ShortUrlModalProps } from '../data';
import { QrColorControl } from './qr-codes/QrColorControl';
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 @@ -27,80 +29,76 @@ const QrCodeModal: FCWithDeps<ShortUrlModalProps, QrCodeModalDeps> = (
const [margin, setMargin] = useState<number>();
const [format, setFormat] = useState<QrCodeFormat>();
const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>();
const [color, setColor] = useState<string>();
const [bgColor, setBgColor] = useState<string>();

const qrCodeColorsSupported = useFeature('qrCodeColors');

const qrCodeUrl = useMemo(
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }),
[shortUrl, size, format, margin, errorCorrection],
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection, color, bgColor }),
[shortUrl, size, format, margin, errorCorrection, color, bgColor],
);
const [modalSize, setModalSize] = useState<'lg' | 'xl'>();
const onImageLoad = useCallback((e: SyntheticEvent<HTMLImageElement>) => {
const image = e.target as HTMLImageElement;
const { naturalWidth } = image;

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

return (
<Modal isOpen={isOpen} toggle={toggle} centered size={modalSize}>
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
<ModalHeader toggle={toggle}>
QR code for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
</ModalHeader>
<ModalBody>
<Row>
<ModalBody className="d-flex flex-column-reverse flex-lg-row gap-3">
<div className="flex-grow-1 d-flex align-items-center justify-content-around text-center">
<img src={qrCodeUrl} alt="QR code" className="shadow" style={{ maxWidth: '100%' }} />
</div>
<div className="d-flex flex-column gap-2 qr-code-modal__controls">
<QrDimensionControl
className="col-sm-6"
name="size"
value={size}
onChange={setSize}
step={10}
min={50}
max={1000}
initial={300}
onChange={setSize}
/>
<QrDimensionControl
className="col-sm-6"
name="margin"
value={margin}
onChange={setMargin}
step={1}
min={0}
max={100}
onChange={setMargin}
/>
<FormGroup className="d-grid col-sm-6">
<QrFormatDropdown format={format} onChange={setFormat} />
</FormGroup>
<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}
alt="QR code"
className="shadow-lg"
style={{ maxWidth: '100%' }}
onLoad={onImageLoad}
/>
<div className="mt-3">
<QrFormatDropdown format={format} onChange={setFormat} />
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} onChange={setErrorCorrection} />

{qrCodeColorsSupported && (
<>
<QrColorControl name="color" initialColor="#000000" color={color} onChange={setColor} />
<QrColorControl name="background" initialColor="#ffffff" color={bgColor} onChange={setBgColor} />
</>
)}

<div className="mt-auto">
<Button
block
color="primary"
onClick={() => {
imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`).catch(() => {});
imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format ?? 'png'}`).catch(() => {
});
}}
>
Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" />
</Button>
</div>
</div>
</ModalBody>
<ModalFooter
className="sticky-bottom justify-content-around"
style={{ backgroundColor: 'var(--primary-color)', zIndex: '1' }}
>
<div className="text-center">
<ExternalLink href={qrCodeUrl} />
<CopyToClipboardIcon text={qrCodeUrl} />
</div>
</ModalFooter>
</Modal>
);
};
Expand Down
30 changes: 30 additions & 0 deletions src/short-urls/helpers/qr-codes/QrColorControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { faArrowRotateLeft } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react';
import { ColorInput } from '../../../utils/components/ColorInput';
import { SubtleButton } from '../../../utils/components/SubtleButton';

export type QrColorControlProps = {
name: string;
color?: string;
/** Initial color to set when transitioning from default to custom */
initialColor: string;
onChange: (newColor?: string) => void;
};

export const QrColorControl: FC<QrColorControlProps> = ({ name, color, initialColor, onChange }) => (
<>
{color === undefined ? (
<SubtleButton className="text-start fst-italic w-100" onClick={() => onChange(initialColor)}>
<span className="indivisible">Customize {name}</span>
</SubtleButton>
) : (
<div className="d-flex gap-1 w-100">
<ColorInput color={color} onChange={onChange} name={name} />
<SubtleButton label={`Default ${name}`} onClick={() => onChange(undefined)}>
<FontAwesomeIcon icon={faArrowRotateLeft} />
</SubtleButton>
</div>
)}
</>
);
37 changes: 11 additions & 26 deletions src/short-urls/helpers/qr-codes/QrDimensionControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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';
import { SubtleButton } from '../../../utils/components/SubtleButton';

export type QrCodeDimensionControlProps = {
name: string;
Expand All @@ -12,29 +12,21 @@ export type QrCodeDimensionControlProps = {
max?: number;
initial?: number;
onChange: (newValue?: number) => void;
className?: string;
};

export const QrDimensionControl: FC<QrCodeDimensionControlProps> = (
{ name, value, step, min, max, onChange, className, initial = min },
{ name, value, step, min, max, onChange, 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)}
>
<>
{value === undefined ? (
<SubtleButton className="text-start fst-italic w-100" onClick={() => onChange(initial)}>
Customize {name}
</Button>
)}
{value !== undefined && (
<div className="d-flex gap-3">
</SubtleButton>
) : (
<div className="d-flex gap-1 w-100">
<div className="d-flex flex-column flex-grow-1">
<label htmlFor={id} className="text-capitalize">{name}: {value}px</label>
<input
Expand All @@ -48,18 +40,11 @@ export const QrDimensionControl: FC<QrCodeDimensionControlProps> = (
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)' }}
>
<SubtleButton label={`Default ${name}`} onClick={() => onChange(undefined)}>
<FontAwesomeIcon icon={faArrowRotateLeft} />
</Button>
</SubtleButton>
</div>
)}
</FormGroup>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ interface QrErrorCorrectionDropdownProps {
export const QrErrorCorrectionDropdown: FC<QrErrorCorrectionDropdownProps> = (
{ errorCorrection, onChange },
) => (
<DropdownBtn text={errorCorrection ? `Error correction (${errorCorrection})` : <i>Default error correction</i>}>
<DropdownBtn
text={errorCorrection ? `Error correction (${errorCorrection})` : <i>Default error correction</i>}
dropdownClassName="w-100"
>
<DropdownItem active={!errorCorrection} onClick={() => onChange(undefined)}>Default</DropdownItem>
<DropdownItem divider tag="hr" />
<DropdownItem active={errorCorrection === 'L'} onClick={() => onChange('L')}>
Expand Down
2 changes: 1 addition & 1 deletion src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ interface QrFormatDropdownProps {
}

export const QrFormatDropdown: FC<QrFormatDropdownProps> = ({ format, onChange }) => (
<DropdownBtn text={format ? `Format (${format})` : <i>Default format</i>}>
<DropdownBtn text={format ? `Format (${format})` : <i>Default format</i>} dropdownClassName="w-100">
<DropdownItem active={!format} onClick={() => onChange(undefined)}>Default</DropdownItem>
<DropdownItem divider tag="hr" />
<DropdownItem active={format === 'png'} onClick={() => onChange('png')}>PNG</DropdownItem>
Expand Down
19 changes: 2 additions & 17 deletions src/tags/helpers/EditTagModal.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Result } from '@shlinkio/shlink-frontend-kit';
import { useCallback, useState } from 'react';
import { Button, Input, InputGroup, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { ShlinkApiError } from '../../common/ShlinkApiError';
import type { FCWithDeps } from '../../container/utils';
import { componentFactory, useDependencies } from '../../container/utils';
import { ColorPicker } from '../../utils/components/ColorPicker';
import { handleEventPreventingDefault } from '../../utils/helpers';
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
import type { TagModalProps } from '../data';
Expand Down Expand Up @@ -45,21 +44,7 @@ const EditTagModal: FCWithDeps<EditTagModalProps, EditTagModalDeps> = (
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
<ModalBody>
<InputGroup>
<div
className="input-group-text p-0 position-relative"
style={{ backgroundColor: color, borderColor: color }}
>
<FontAwesomeIcon
icon={colorIcon}
className="position-absolute top-50 start-50 translate-middle text-white"
/>
<Input
className="form-control-color opacity-0"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
/>
</div>
<ColorPicker color={color} onChange={setColor} className="input-group-text" name="tag-color" />
<Input
value={newTagName}
placeholder="Tag"
Expand Down
22 changes: 22 additions & 0 deletions src/utils/components/ColorInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useElementRef } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { Input, InputGroup } from 'reactstrap';
import type { ColorPickerProps } from './ColorPicker';
import { ColorPicker } from './ColorPicker';

export const ColorInput: FC<Omit<ColorPickerProps, 'className'>> = ({ color, onChange, name }) => {
const colorPickerRef = useElementRef<HTMLInputElement>();

return (
<InputGroup>
<ColorPicker name={name} color={color} onChange={onChange} className="input-group-text" ref={colorPickerRef} />
<Input
readOnly
value={color}
onClick={() => colorPickerRef.current?.click()}
aria-label={name}
data-testid="text-input"
/>
</InputGroup>
);
};
38 changes: 38 additions & 0 deletions src/utils/components/ColorPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { clsx } from 'clsx';
import { forwardRef } from 'react';
import { Input } from 'reactstrap';
import { isLightColor } from '../helpers/color';

export type ColorPickerProps = {
name: string;
color: string;
onChange: (newColor: string) => void;
className?: string;
};

export const ColorPicker = forwardRef<HTMLInputElement, ColorPickerProps>(
({ name, color, onChange, className }, ref) => (
<div
className={clsx('p-0 position-relative', className)}
style={{ backgroundColor: color, borderColor: color }}
>
<FontAwesomeIcon
icon={colorIcon}
className="position-absolute top-50 start-50 translate-middle"
// Text color should be dynamically calculated to keep contrast
style={{ color: isLightColor(color.substring(1)) ? '#000' : 'fff' }}
/>
<Input
className="form-control-color opacity-0"
type="color"
value={color}
onChange={(e) => onChange(e.target.value)}
innerRef={ref}
name={name}
aria-label={name}
/>
</div>
),
);
Loading

0 comments on commit 2799497

Please sign in to comment.