Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions __TESTS__/image-converter.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/* eslint-disable import/no-extraneous-dependencies */
import {
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';

import ImageConverter from '../pages/image-converter';

describe('Image Converter', () => {
it('renders without crashing', () => {
render(<ImageConverter />);

expect(screen.getByText(/Upload image/i)).toBeInTheDocument();
});

it('an image can be uploaded and displayed', async () => {
render(<ImageConverter />);

expect(
screen.queryByTestId('image-preview'),
).not.toBeInTheDocument();

const file = new File(['abc'], 'test.png', { type: 'image/png' });
const input = screen.getByLabelText('Upload image');
fireEvent.change(input, { target: { files: [file] } });

await waitFor(() => {
expect(screen.getByTestId('image-preview')).toBeInTheDocument();
});
});

it('the type can be changed', async () => {
render(<ImageConverter />);
const user = userEvent.setup();

const fileTypeControl: HTMLInputElement =
screen.getByLabelText('PNG');
expect(fileTypeControl.checked).toBe(false);
await user.click(fileTypeControl);
expect(fileTypeControl.checked).toBe(true);
});
});
6 changes: 6 additions & 0 deletions data/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Css from '@mui/icons-material/Css';
import DataObject from '@mui/icons-material/DataObject';
import Fingerprint from '@mui/icons-material/Fingerprint';
import HomeIcon from '@mui/icons-material/Home';
import Image from '@mui/icons-material/Image';
import LinkIcon from '@mui/icons-material/Link';
import Looks6 from '@mui/icons-material/Looks6';

Expand Down Expand Up @@ -56,6 +57,11 @@ const navItems = [
href: '/lorem-ipsum',
Icon: ArticleIcon,
},
{
title: 'Image Converter',
href: '/image-converter',
Icon: Image,
},
];

export default navItems;
203 changes: 203 additions & 0 deletions pages/image-converter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Container from '@mui/material/Container';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormLabel from '@mui/material/FormLabel';
import Grid from '@mui/material/Grid';
import Radio from '@mui/material/Radio';
import RadioGroup from '@mui/material/RadioGroup';
import Slider from '@mui/material/Slider';
import { styled } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
import React, { useState } from 'react';

import Heading from '../components/Heading';
import Layout from '../components/Layout';
import useLocalState from '../hooks/useLocalState';

enum FileType {
PNG = 'image/png',
JPG = 'image/jpeg',
WEBP = 'image/webp',
}

const FILE_TYPE_EXTENSIONS = {
[FileType.PNG]: '.png',
[FileType.JPG]: '.jpg',
[FileType.WEBP]: '.webp',
};

const FILE_TYPE_OPTIONS = [
{ label: 'PNG', value: FileType.PNG },
{ label: 'JPG', value: FileType.JPG },
{ label: 'WebP', value: FileType.WEBP },
];

function renameFile(filename: string, fileType: FileType) {
return (
filename.substring(0, filename.lastIndexOf('.')) +
FILE_TYPE_EXTENSIONS[fileType]
);
}

function formatSliderLabel(value: number) {
return `${Math.round(value * 100)}%`;
}

const PreviewImage = styled('img')({});

export default function ImageConverterPage() {
const [file, setFile] = useState<{ raw: File; imageSrc: string }>();
const [fileType, setFileType] = useLocalState<FileType>({
key: 'image-converter-file-type',
defaultValue: FileType.WEBP,
});
const [quality, setQuality] = useLocalState<number>({
key: 'image-converter-quality',
defaultValue: 0.7,
});
const handleInputChange: React.ChangeEventHandler<
HTMLInputElement
> = (event) => {
// Bail immediately if the user canceled
if (
!event.currentTarget.files ||
event.currentTarget.files.length === 0
) {
return;
}
const eventFile = event.currentTarget.files[0];
const reader = new FileReader();
reader.onloadend = () => {
setFile({ raw: eventFile, imageSrc: reader.result as string });
};
reader.readAsDataURL(eventFile);
};
const handleFileTypeChange: React.ChangeEventHandler<
HTMLInputElement
> = (event) => {
setFileType(event.currentTarget.value as FileType);
};
const handleDownload = () => {
if (!file) {
return;
}
// Create a canvas of the target size to draw the image
const canvas = document.createElement('canvas');
const image = new Image();
image.src = file.imageSrc;
canvas.width = image.width;
canvas.height = image.height;

// Draw the image on the canvas and convert it to the selected type
const ctx = canvas.getContext('2d');
ctx?.drawImage(image, 0, 0);
const dataUrl = canvas.toDataURL(fileType, quality);

// Create a dummy link to trigger the download
const link = document.createElement('a');
link.href = dataUrl;
link.download = renameFile(file.raw.name, fileType);
link.click();
};
return (
<Layout title='Image Converter'>
<Heading>Image Converter</Heading>
<Container>
<Grid
container
direction={{ xs: 'column', md: 'row' }}
>
<Grid
item
xs={6}
sx={{
height: '500px',
borderRadius: '0.5rem',
background: 'rgba(0,0,0,.5)',
overflow: 'hidden',
}}
>
{file && (
<PreviewImage
data-testid='image-preview'
alt=''
src={file?.imageSrc}
sx={{
width: '100%',
height: '100%',
objectFit: 'contain',
}}
/>
)}
</Grid>
<Grid
item
xs={6}
sx={{ p: 2 }}
>
<Button
variant='contained'
component='label'
>
Upload image
<input
type='file'
accept='image/*'
style={{ display: 'none' }}
onChange={handleInputChange}
/>
</Button>

<FormControl sx={{ display: 'flex', my: 3 }}>
<FormLabel id='image-type-label'>File Type</FormLabel>
<RadioGroup
aria-labelledby='image-type-label'
name='image-type'
value={fileType}
onChange={handleFileTypeChange}
row
>
{FILE_TYPE_OPTIONS.map(({ label, value }) => (
<FormControlLabel
key={value}
value={value}
control={<Radio />}
label={label}
/>
))}
</RadioGroup>
</FormControl>
<Box sx={{ my: 3 }}>
<Typography
id='quality-slider'
gutterBottom
>
Quality
</Typography>
<Slider
value={quality}
min={0}
step={0.05}
max={1}
onChange={(e, value) => setQuality(value as number)}
valueLabelDisplay='auto'
valueLabelFormat={formatSliderLabel}
disabled={fileType === FileType.PNG}
aria-labelledby='quality-slider'
/>
</Box>
<Button
variant='contained'
onClick={handleDownload}
disabled={!file}
>
Download
</Button>
</Grid>
</Grid>
</Container>
</Layout>
);
}