Skip to content

Commit 2799497

Browse files
authored
Merge pull request #492 from acelaya-forks/feature/qr-code-color
Support color and background color in QR codes modal
2 parents 538f3a7 + 18c27d1 commit 2799497

20 files changed

+352
-149
lines changed

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
44

55
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).
66

7+
## [Unreleased]
8+
### Added
9+
* [#491](https://github.com/shlinkio/shlink-web-component/issues/491) Add support for colors in QR code configurator.
10+
11+
### Changed
12+
* *Nothing*
13+
14+
### Deprecated
15+
* *Nothing*
16+
17+
### Removed
18+
* *Nothing*
19+
20+
### Fixed
21+
* *Nothing*
22+
23+
724
## [0.10.1] - 2024-10-19
825
### Added
926
* *Nothing*
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
@import '../../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
2+
3+
.qr-code-modal__controls {
4+
@media (min-width: $lgMin) {
5+
width: 16rem;
6+
}
7+
}

src/short-urls/helpers/QrCodeModal.tsx

Lines changed: 42 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons';
22
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3-
import type { SyntheticEvent } from 'react';
4-
import { useCallback, useMemo, useState } from 'react';
3+
import { useMemo, useState } from 'react';
54
import { ExternalLink } from 'react-external-link';
6-
import { Button, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
5+
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
76
import type { FCWithDeps } from '../../container/utils';
87
import { componentFactory, useDependencies } from '../../container/utils';
98
import { CopyToClipboardIcon } from '../../utils/components/CopyToClipboardIcon';
9+
import { useFeature } from '../../utils/features';
1010
import type { QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
1111
import { buildQrCodeUrl } from '../../utils/helpers/qrCodes';
1212
import type { ImageDownloader } from '../../utils/services/ImageDownloader';
1313
import type { ShortUrlModalProps } from '../data';
14+
import { QrColorControl } from './qr-codes/QrColorControl';
1415
import { QrDimensionControl } from './qr-codes/QrDimensionControl';
1516
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
1617
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
18+
import './QrCodeModal.scss';
1719

1820
type QrCodeModalDeps = {
1921
ImageDownloader: ImageDownloader
@@ -27,80 +29,76 @@ const QrCodeModal: FCWithDeps<ShortUrlModalProps, QrCodeModalDeps> = (
2729
const [margin, setMargin] = useState<number>();
2830
const [format, setFormat] = useState<QrCodeFormat>();
2931
const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>();
32+
const [color, setColor] = useState<string>();
33+
const [bgColor, setBgColor] = useState<string>();
34+
35+
const qrCodeColorsSupported = useFeature('qrCodeColors');
36+
3037
const qrCodeUrl = useMemo(
31-
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }),
32-
[shortUrl, size, format, margin, errorCorrection],
38+
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection, color, bgColor }),
39+
[shortUrl, size, format, margin, errorCorrection, color, bgColor],
3340
);
34-
const [modalSize, setModalSize] = useState<'lg' | 'xl'>();
35-
const onImageLoad = useCallback((e: SyntheticEvent<HTMLImageElement>) => {
36-
const image = e.target as HTMLImageElement;
37-
const { naturalWidth } = image;
38-
39-
if (naturalWidth < 500) {
40-
setModalSize(undefined);
41-
} else {
42-
setModalSize(naturalWidth < 800 ? 'lg' : 'xl');
43-
}
44-
}, []);
4541

4642
return (
47-
<Modal isOpen={isOpen} toggle={toggle} centered size={modalSize}>
43+
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
4844
<ModalHeader toggle={toggle}>
4945
QR code for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
5046
</ModalHeader>
51-
<ModalBody>
52-
<Row>
47+
<ModalBody className="d-flex flex-column-reverse flex-lg-row gap-3">
48+
<div className="flex-grow-1 d-flex align-items-center justify-content-around text-center">
49+
<img src={qrCodeUrl} alt="QR code" className="shadow" style={{ maxWidth: '100%' }} />
50+
</div>
51+
<div className="d-flex flex-column gap-2 qr-code-modal__controls">
5352
<QrDimensionControl
54-
className="col-sm-6"
5553
name="size"
5654
value={size}
55+
onChange={setSize}
5756
step={10}
5857
min={50}
5958
max={1000}
6059
initial={300}
61-
onChange={setSize}
6260
/>
6361
<QrDimensionControl
64-
className="col-sm-6"
6562
name="margin"
6663
value={margin}
64+
onChange={setMargin}
6765
step={1}
6866
min={0}
6967
max={100}
70-
onChange={setMargin}
7168
/>
72-
<FormGroup className="d-grid col-sm-6">
73-
<QrFormatDropdown format={format} onChange={setFormat} />
74-
</FormGroup>
75-
<FormGroup className="col-sm-6">
76-
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} onChange={setErrorCorrection} />
77-
</FormGroup>
78-
</Row>
79-
<div className="text-center">
80-
<div className="mb-3">
81-
<ExternalLink href={qrCodeUrl} />
82-
<CopyToClipboardIcon text={qrCodeUrl} />
83-
</div>
84-
<img
85-
src={qrCodeUrl}
86-
alt="QR code"
87-
className="shadow-lg"
88-
style={{ maxWidth: '100%' }}
89-
onLoad={onImageLoad}
90-
/>
91-
<div className="mt-3">
69+
<QrFormatDropdown format={format} onChange={setFormat} />
70+
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} onChange={setErrorCorrection} />
71+
72+
{qrCodeColorsSupported && (
73+
<>
74+
<QrColorControl name="color" initialColor="#000000" color={color} onChange={setColor} />
75+
<QrColorControl name="background" initialColor="#ffffff" color={bgColor} onChange={setBgColor} />
76+
</>
77+
)}
78+
79+
<div className="mt-auto">
9280
<Button
9381
block
9482
color="primary"
9583
onClick={() => {
96-
imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`).catch(() => {});
84+
imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format ?? 'png'}`).catch(() => {
85+
});
9786
}}
9887
>
9988
Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" />
10089
</Button>
10190
</div>
10291
</div>
10392
</ModalBody>
93+
<ModalFooter
94+
className="sticky-bottom justify-content-around"
95+
style={{ backgroundColor: 'var(--primary-color)', zIndex: '1' }}
96+
>
97+
<div className="text-center">
98+
<ExternalLink href={qrCodeUrl} />
99+
<CopyToClipboardIcon text={qrCodeUrl} />
100+
</div>
101+
</ModalFooter>
104102
</Modal>
105103
);
106104
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { faArrowRotateLeft } from '@fortawesome/free-solid-svg-icons';
2+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3+
import type { FC } from 'react';
4+
import { ColorInput } from '../../../utils/components/ColorInput';
5+
import { SubtleButton } from '../../../utils/components/SubtleButton';
6+
7+
export type QrColorControlProps = {
8+
name: string;
9+
color?: string;
10+
/** Initial color to set when transitioning from default to custom */
11+
initialColor: string;
12+
onChange: (newColor?: string) => void;
13+
};
14+
15+
export const QrColorControl: FC<QrColorControlProps> = ({ name, color, initialColor, onChange }) => (
16+
<>
17+
{color === undefined ? (
18+
<SubtleButton className="text-start fst-italic w-100" onClick={() => onChange(initialColor)}>
19+
<span className="indivisible">Customize {name}</span>
20+
</SubtleButton>
21+
) : (
22+
<div className="d-flex gap-1 w-100">
23+
<ColorInput color={color} onChange={onChange} name={name} />
24+
<SubtleButton label={`Default ${name}`} onClick={() => onChange(undefined)}>
25+
<FontAwesomeIcon icon={faArrowRotateLeft} />
26+
</SubtleButton>
27+
</div>
28+
)}
29+
</>
30+
);

src/short-urls/helpers/qr-codes/QrDimensionControl.tsx

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { faArrowRotateLeft } from '@fortawesome/free-solid-svg-icons';
22
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
33
import type { FC } from 'react';
44
import { useId } from 'react';
5-
import { Button, FormGroup } from 'reactstrap';
5+
import { SubtleButton } from '../../../utils/components/SubtleButton';
66

77
export type QrCodeDimensionControlProps = {
88
name: string;
@@ -12,29 +12,21 @@ export type QrCodeDimensionControlProps = {
1212
max?: number;
1313
initial?: number;
1414
onChange: (newValue?: number) => void;
15-
className?: string;
1615
};
1716

1817
export const QrDimensionControl: FC<QrCodeDimensionControlProps> = (
19-
{ name, value, step, min, max, onChange, className, initial = min },
18+
{ name, value, step, min, max, onChange, initial = min },
2019
) => {
2120
const id = useId();
2221

2322
return (
24-
<FormGroup className={className}>
25-
{value === undefined && (
26-
<Button
27-
outline
28-
color="link"
29-
className="text-start fst-italic w-100"
30-
style={{ color: 'var(--input-text-color)', borderColor: 'var(--border-color)' }}
31-
onClick={() => onChange(initial)}
32-
>
23+
<>
24+
{value === undefined ? (
25+
<SubtleButton className="text-start fst-italic w-100" onClick={() => onChange(initial)}>
3326
Customize {name}
34-
</Button>
35-
)}
36-
{value !== undefined && (
37-
<div className="d-flex gap-3">
27+
</SubtleButton>
28+
) : (
29+
<div className="d-flex gap-1 w-100">
3830
<div className="d-flex flex-column flex-grow-1">
3931
<label htmlFor={id} className="text-capitalize">{name}: {value}px</label>
4032
<input
@@ -48,18 +40,11 @@ export const QrDimensionControl: FC<QrCodeDimensionControlProps> = (
4840
onChange={(e) => onChange(Number(e.target.value))}
4941
/>
5042
</div>
51-
<Button
52-
aria-label={`Default ${name}`}
53-
title={`Default ${name}`}
54-
outline
55-
color="link"
56-
onClick={() => onChange(undefined)}
57-
style={{ color: 'var(--input-text-color)', borderColor: 'var(--border-color)' }}
58-
>
43+
<SubtleButton label={`Default ${name}`} onClick={() => onChange(undefined)}>
5944
<FontAwesomeIcon icon={faArrowRotateLeft} />
60-
</Button>
45+
</SubtleButton>
6146
</div>
6247
)}
63-
</FormGroup>
48+
</>
6449
);
6550
};

src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ interface QrErrorCorrectionDropdownProps {
1111
export const QrErrorCorrectionDropdown: FC<QrErrorCorrectionDropdownProps> = (
1212
{ errorCorrection, onChange },
1313
) => (
14-
<DropdownBtn text={errorCorrection ? `Error correction (${errorCorrection})` : <i>Default error correction</i>}>
14+
<DropdownBtn
15+
text={errorCorrection ? `Error correction (${errorCorrection})` : <i>Default error correction</i>}
16+
dropdownClassName="w-100"
17+
>
1518
<DropdownItem active={!errorCorrection} onClick={() => onChange(undefined)}>Default</DropdownItem>
1619
<DropdownItem divider tag="hr" />
1720
<DropdownItem active={errorCorrection === 'L'} onClick={() => onChange('L')}>

src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ interface QrFormatDropdownProps {
99
}
1010

1111
export const QrFormatDropdown: FC<QrFormatDropdownProps> = ({ format, onChange }) => (
12-
<DropdownBtn text={format ? `Format (${format})` : <i>Default format</i>}>
12+
<DropdownBtn text={format ? `Format (${format})` : <i>Default format</i>} dropdownClassName="w-100">
1313
<DropdownItem active={!format} onClick={() => onChange(undefined)}>Default</DropdownItem>
1414
<DropdownItem divider tag="hr" />
1515
<DropdownItem active={format === 'png'} onClick={() => onChange('png')}>PNG</DropdownItem>

src/tags/helpers/EditTagModal.tsx

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
2-
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
31
import { Result } from '@shlinkio/shlink-frontend-kit';
42
import { useCallback, useState } from 'react';
53
import { Button, Input, InputGroup, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
64
import { ShlinkApiError } from '../../common/ShlinkApiError';
75
import type { FCWithDeps } from '../../container/utils';
86
import { componentFactory, useDependencies } from '../../container/utils';
7+
import { ColorPicker } from '../../utils/components/ColorPicker';
98
import { handleEventPreventingDefault } from '../../utils/helpers';
109
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
1110
import type { TagModalProps } from '../data';
@@ -45,21 +44,7 @@ const EditTagModal: FCWithDeps<EditTagModalProps, EditTagModalDeps> = (
4544
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
4645
<ModalBody>
4746
<InputGroup>
48-
<div
49-
className="input-group-text p-0 position-relative"
50-
style={{ backgroundColor: color, borderColor: color }}
51-
>
52-
<FontAwesomeIcon
53-
icon={colorIcon}
54-
className="position-absolute top-50 start-50 translate-middle text-white"
55-
/>
56-
<Input
57-
className="form-control-color opacity-0"
58-
type="color"
59-
value={color}
60-
onChange={(e) => setColor(e.target.value)}
61-
/>
62-
</div>
47+
<ColorPicker color={color} onChange={setColor} className="input-group-text" name="tag-color" />
6348
<Input
6449
value={newTagName}
6550
placeholder="Tag"

src/utils/components/ColorInput.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useElementRef } from '@shlinkio/shlink-frontend-kit';
2+
import type { FC } from 'react';
3+
import { Input, InputGroup } from 'reactstrap';
4+
import type { ColorPickerProps } from './ColorPicker';
5+
import { ColorPicker } from './ColorPicker';
6+
7+
export const ColorInput: FC<Omit<ColorPickerProps, 'className'>> = ({ color, onChange, name }) => {
8+
const colorPickerRef = useElementRef<HTMLInputElement>();
9+
10+
return (
11+
<InputGroup>
12+
<ColorPicker name={name} color={color} onChange={onChange} className="input-group-text" ref={colorPickerRef} />
13+
<Input
14+
readOnly
15+
value={color}
16+
onClick={() => colorPickerRef.current?.click()}
17+
aria-label={name}
18+
data-testid="text-input"
19+
/>
20+
</InputGroup>
21+
);
22+
};

src/utils/components/ColorPicker.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
2+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3+
import { clsx } from 'clsx';
4+
import { forwardRef } from 'react';
5+
import { Input } from 'reactstrap';
6+
import { isLightColor } from '../helpers/color';
7+
8+
export type ColorPickerProps = {
9+
name: string;
10+
color: string;
11+
onChange: (newColor: string) => void;
12+
className?: string;
13+
};
14+
15+
export const ColorPicker = forwardRef<HTMLInputElement, ColorPickerProps>(
16+
({ name, color, onChange, className }, ref) => (
17+
<div
18+
className={clsx('p-0 position-relative', className)}
19+
style={{ backgroundColor: color, borderColor: color }}
20+
>
21+
<FontAwesomeIcon
22+
icon={colorIcon}
23+
className="position-absolute top-50 start-50 translate-middle"
24+
// Text color should be dynamically calculated to keep contrast
25+
style={{ color: isLightColor(color.substring(1)) ? '#000' : 'fff' }}
26+
/>
27+
<Input
28+
className="form-control-color opacity-0"
29+
type="color"
30+
value={color}
31+
onChange={(e) => onChange(e.target.value)}
32+
innerRef={ref}
33+
name={name}
34+
aria-label={name}
35+
/>
36+
</div>
37+
),
38+
);

0 commit comments

Comments
 (0)