From 5aa1e61cb7cd6adff41ab9cdbffb711fe33adf7e Mon Sep 17 00:00:00 2001 From: Gaston Flores Date: Sat, 8 Oct 2022 15:44:46 +0000 Subject: [PATCH 1/6] feat: add image converter --- data/nav.ts | 6 ++ pages/image-converter.tsx | 195 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 pages/image-converter.tsx 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..ed1bf0b --- /dev/null +++ b/pages/image-converter.tsx @@ -0,0 +1,195 @@ +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'; + +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 JsonPage() { + const [file, setFile] = useState<{ raw: File; imageSrc: string }>(); + const [fileType, setFileType] = useState(FileType.WEBP); + const [quality, setQuality] = useState(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(); + console.log(eventFile); + 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} + aria-labelledby='quality-slider' + /> + + + + + + + ); +} From 5918f7c0891f62d8d3dfef3051c7cd75ea09a4b9 Mon Sep 17 00:00:00 2001 From: Gaston Flores Date: Sat, 8 Oct 2022 17:37:57 +0000 Subject: [PATCH 2/6] chore: rename component --- pages/image-converter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/image-converter.tsx b/pages/image-converter.tsx index ed1bf0b..9130cd5 100644 --- a/pages/image-converter.tsx +++ b/pages/image-converter.tsx @@ -46,7 +46,7 @@ function formatSliderLabel(value: number) { const PreviewImage = styled('img')({}); -export default function JsonPage() { +export default function ImageConverterPage() { const [file, setFile] = useState<{ raw: File; imageSrc: string }>(); const [fileType, setFileType] = useState(FileType.WEBP); const [quality, setQuality] = useState(0.7); From f81af98e07824055082abbe9ffc14e8f5a0fcf1d Mon Sep 17 00:00:00 2001 From: Gaston Flores Date: Sat, 8 Oct 2022 17:39:52 +0000 Subject: [PATCH 3/6] feat: disable quality input when using PNG --- pages/image-converter.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/pages/image-converter.tsx b/pages/image-converter.tsx index 9130cd5..1fa6e0d 100644 --- a/pages/image-converter.tsx +++ b/pages/image-converter.tsx @@ -177,6 +177,7 @@ export default function ImageConverterPage() { onChange={(e, value) => setQuality(value as number)} valueLabelDisplay='auto' valueLabelFormat={formatSliderLabel} + disabled={fileType === FileType.PNG} aria-labelledby='quality-slider' /> From c4df42dc329a9f0a7fe538700228f921069c12a7 Mon Sep 17 00:00:00 2001 From: Gaston Flores Date: Sat, 8 Oct 2022 17:40:34 +0000 Subject: [PATCH 4/6] chore: remove console.log --- pages/image-converter.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/pages/image-converter.tsx b/pages/image-converter.tsx index 1fa6e0d..a31b6c0 100644 --- a/pages/image-converter.tsx +++ b/pages/image-converter.tsx @@ -62,7 +62,6 @@ export default function ImageConverterPage() { } const eventFile = event.currentTarget.files[0]; const reader = new FileReader(); - console.log(eventFile); reader.onloadend = () => { setFile({ raw: eventFile, imageSrc: reader.result as string }); }; From 6e562e4b03e06ee94bc26f908be72505dc0ca20e Mon Sep 17 00:00:00 2001 From: Gaston Flores Date: Sat, 8 Oct 2022 17:45:02 +0000 Subject: [PATCH 5/6] feat: use useLocalState to persist quality/fileType values --- pages/image-converter.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pages/image-converter.tsx b/pages/image-converter.tsx index a31b6c0..a3ca3ee 100644 --- a/pages/image-converter.tsx +++ b/pages/image-converter.tsx @@ -14,6 +14,7 @@ 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', @@ -48,8 +49,14 @@ const PreviewImage = styled('img')({}); export default function ImageConverterPage() { const [file, setFile] = useState<{ raw: File; imageSrc: string }>(); - const [fileType, setFileType] = useState(FileType.WEBP); - const [quality, setQuality] = useState(0.7); + 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) => { From a47cd6be8c645d6efa42889035d7f99aabfb8abe Mon Sep 17 00:00:00 2001 From: Gaston Flores Date: Sat, 8 Oct 2022 20:33:02 +0000 Subject: [PATCH 6/6] test: add tests for image converter --- __TESTS__/image-converter.spec.tsx | 46 ++++++++++++++++++++++++++++++ pages/image-converter.tsx | 1 + 2 files changed, 47 insertions(+) create mode 100644 __TESTS__/image-converter.spec.tsx 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/pages/image-converter.tsx b/pages/image-converter.tsx index a3ca3ee..5de6268 100644 --- a/pages/image-converter.tsx +++ b/pages/image-converter.tsx @@ -121,6 +121,7 @@ export default function ImageConverterPage() { > {file && (