diff --git a/__TESTS__/image-converter.spec.tsx b/__TESTS__/image-converter.spec.tsx
new file mode 100644
index 0000000..9ac8b26
--- /dev/null
+++ b/__TESTS__/image-converter.spec.tsx
@@ -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();
+
+ expect(screen.getByText(/Upload image/i)).toBeInTheDocument();
+ });
+
+ it('an image can be uploaded and displayed', async () => {
+ render();
+
+ 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();
+ const user = userEvent.setup();
+
+ const fileTypeControl: HTMLInputElement =
+ screen.getByLabelText('PNG');
+ expect(fileTypeControl.checked).toBe(false);
+ await user.click(fileTypeControl);
+ expect(fileTypeControl.checked).toBe(true);
+ });
+});
diff --git a/data/nav.ts b/data/nav.ts
index df2c2ca..0226963 100644
--- a/data/nav.ts
+++ b/data/nav.ts
@@ -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';
@@ -56,6 +57,11 @@ const navItems = [
href: '/lorem-ipsum',
Icon: ArticleIcon,
},
+ {
+ title: 'Image Converter',
+ href: '/image-converter',
+ Icon: Image,
+ },
];
export default navItems;
diff --git a/pages/image-converter.tsx b/pages/image-converter.tsx
new file mode 100644
index 0000000..5de6268
--- /dev/null
+++ b/pages/image-converter.tsx
@@ -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({
+ key: 'image-converter-file-type',
+ defaultValue: FileType.WEBP,
+ });
+ const [quality, setQuality] = useLocalState({
+ 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 (
+
+ Image Converter
+
+
+
+ {file && (
+
+ )}
+
+
+
+
+
+ File Type
+
+ {FILE_TYPE_OPTIONS.map(({ label, value }) => (
+ }
+ label={label}
+ />
+ ))}
+
+
+
+
+ Quality
+
+ setQuality(value as number)}
+ valueLabelDisplay='auto'
+ valueLabelFormat={formatSliderLabel}
+ disabled={fileType === FileType.PNG}
+ aria-labelledby='quality-slider'
+ />
+
+
+
+
+
+
+ );
+}