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' + /> + + + + + + + ); +}