Building React SPA for Base64 images encoding
Intro
Once in a while when building frontend applications you would like to deliver some of the images as separate assets rather than bundle into html / css directly.
Main reasons for including images directly into page could be:
- no need to setup assets pipeline for images
(which is not a case now due to already implemented static assets in
react-scrits
- relatively small image size
- big number of small images on a page. In this case actually it is better to use css-sprites
- need to deliver small images with initial page load and no blink effect.
Possibly some other reasons exists. This guide would show how to build SPA which would accept image files and generate base64 encoded payload data url
Contents
Sections in this guide:
- Generate Application with react-scripts
- Adding Drag&Drop dropzone
- Using
material-ui
for UI styling - Deploy to gitlab pages
Impatient section
Code available at https://gitlab.com/base64img/base64img.gitlab.io
Application deployed at https://base64img.gitlab.io/
Drag & Drop some images or click dropzone for selecting image files
Generate Application with react-scripts
Navigate to some directory. And execute create-react-app
without installation:
$ npx create-react-app base64img.gitlab.io --template typescript
This would create application in base64img.gitlab.io
folder.
Run fresh application:
$ cd base64img.gitlab.io
$ yarn start
Browser would open http://localhost:3000
automatically.
Check generated README.md
within project or even read about more options available for create-react-app
here
Adding Drag&Drop dropzone
Next, add react-dropzone to be able to upload files.
Files would remain in browser only. No backend needed for this SPA.
$ yarn add react-dropzone
Modify main src/App.tsx
:
import React, { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
export function App() {
const [encodedFiles, setEncodedFiles] = useState<File[]>([]);
const onDrop = useCallback((acceptedFiles: File[]) => {
setEncodedFiles(acceptedFiles);
}, []);
const { acceptedFiles, getRootProps, getInputProps } = useDropzone({
accept: ["image/jpeg", "image/png", "image/gif", "image/*"],
maxSize: 100000000, // 100 mb
multiple: true,
onDrop
});
return (
<div >
<div {...getRootProps()}>
<input {...getInputProps()} />
<p>Drag 'n' drop some files here, or click to select files</p>
</div>
{encodedFiles.map((file: File, idx: number) => {
return (
<div key={idx}>
<b>{file.name}</b> ({file.size} bytes)
</div>
)
})}
</div>
);
}
useDropzone
is a special hook provided by react-dropzone
and gives access to useful properties provided by Drag&Drop.
It also accepts properties to configure behavior of file input component:
accept
Specifies which file types should be only picked up by file input. Based on react-dropzone/attr-acceptmaxSize
Maximum file size (in bytes)multiple
Allow to accept multiple filesonDrop
Function to be called after each file selection happened, regardless of files were accepted or not. Has signature:onDrop(acceptedFiles: T[], rejectedFiles: T[], event: DropEvent): void
. Also useful to process potentially rejected files, to show some kind of error.
For more options check documentation
onDrop
would be so far just useCallback
hook which sets encodedFiles
in state.
For UI part there are some styles given by react-dropzone
:
const { getRootProps, getInputProps } = useDropzone();
<div {...getRootProps()}>
<input {...getInputProps()} />
<p>Drag 'n' drop some files here, or click to select files</p>
</div>
Selected files then rendered into html:
{encodedFiles.map((file: File, idx: number) => {
return (
<div key={idx}>
<b>{file.name}</b> ({file.size} bytes)
</div>
)
})}
Run application:
$ yarn start
Drag&Drop some files to see their names rendered on page.
Add base64 payload encoding
To be able to access files from users file system FileReader would be used. It cant access ANY file, just given ones.
loadFile
would wrap everything related to FileReader
:
const loadFile = (file: File): Promise<EncodedFile> => new Promise((res, rej) => {
var reader = new FileReader();
let base = {
name: file.name,
size: file.size,
}
reader.addEventListener("abort", e => rej(`File upload aborted:${e}`));
reader.addEventListener("error", e => rej(`File upload error: ${e}`));
reader.addEventListener("load", () => res({
...base,
encoded: reader.result as string
}), false);
reader.readAsDataURL(file);
})
On “load” event it would return reader.result
Those two types would represent success and error records in application:
interface EncodedFile {
name: string;
size: number;
encoded: string; // base64 encoded content
}
interface ErrorFile {
name: string;
size: number;
error: string; // error description
}
Add some more state and use loadFile
function:
const [encodedFiles, setEncodedFiles] = useState<EncodedFile[]>([]);
const [errors, setErrors] = useState<ErrorFile[]>([]);
const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: File[]) => {
setErrors(rejectFiles(rejectedFiles)); // set/reset errors
setEncodedFiles([]); // reset UI
acceptedFiles.forEach((file: File) =>
loadFile(file)
.then(encFile => setEncodedFiles(list => [...list, encFile]))
.catch(error => setErrors(list => [...list, {
name: file.name,
size: file.size,
error
}]))
);
}, []);
Where rejectFiles
:
const rejectFiles = (files: File[]): ErrorFile[] => files.map(f => ({
name: f.name,
size: f.size,
error: 'File rejected'
}))
Within modified onDrop
:
- Reset previous UI results with
setEncodedFiles([]);
, - Process errors
setErrors(rejectFiles(rejectedFiles))
- Process accepted files with
acceptedFiles.forEach
Images with base64 data would be available within state variable encodedFiles
, and uploading errors/rejected files in errors
.
Using material-ui
For UI styling would be used material-ui which provide React components and CSS-in-JS solution for components styling.
Add material-ui:
$ yarn add @material-ui/core
App.jsx
would have such lines added:
import { makeStyles } from '@material-ui/core/styles';
import { Container, Card, CardContent, Typography, TextField, LinearProgress } from '@material-ui/core';
const useStyles = makeStyles(theme => ({
root: {
flexGrow: 1,
},
header: {
padding: '20px 0'
},
dropzone: {
flex: '1',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '20px',
borderWidth: '2px',
borderRadius: '2px',
borderColor: '#eeeeee',
borderStyle: 'dashed',
backgroundColor: '#fafafa',
color: '#bdbdbd',
outline: 'none',
transition: 'border .24s ease-in-out'
},
cardContent: {
minHeight: 50,
minWidth: 50,
maxWidth: 100,
maxHeight: 100,
margin: 20
}
}));
export function App() {
const classes = useStyles();
const [encodedFiles, setEncodedFiles] = useState<EncodedFile[]>([]);
const [errors, setErrors] = useState<ErrorFile[]>([]);
const { acceptedFiles, getRootProps, getInputProps } = useDropzone({/** ... **/})
/** dropzone logic skipped here **/
return (
<div className={classes.root}>
<Container maxWidth="md">
<Typography variant="h4" className={classes.header}>
Base 64 encode image to be used inline in "src" attribute
</Typography>
<div {...getRootProps({ className: classes.dropzone })}>
<input {...getInputProps()} />
<p>Drag 'n' drop some files here, or click to select files</p>
</div>
<div>
{errors && errors.map((err: ErrorFile, idx: number) => (
<div key={idx}>
<Typography color="secondary" variant="h5">
<b>{err.name}</b> ({err.size} bytes): {err.error}
</Typography>
</div>
))}
</div>
<div>
{acceptedFiles.length !== encodedFiles.length &&
<div>
<Typography color="secondary" variant="h5">
Processing {acceptedFiles.length - encodedFiles.length} files. Wait a moment ...
</Typography>
<br />
<LinearProgress />
<br />
</div>
}
</div>
{encodedFiles && encodedFiles.map((file: EncodedFile, idx: number) => (
<div key={idx}>
<Card>
<img src={file.encoded} alt={file.name} className={classes.cardContent} />
<CardContent>
<Typography gutterBottom variant="h5" component="h2">
<b>{file.name}</b> ({file.size} bytes)
</Typography>
<TextField
label="Full img tag"
fullWidth
value={`<img alt="${file.name}" src="${file.encoded}"/>`}
margin="normal"
variant="outlined"
/>
<TextField
label="Base64 encoded. Copy-paste into 'src' attribute"
fullWidth
value={file.encoded}
margin="normal"
InputLabelProps={{
shrink: true,
}}
variant="outlined"
multiline
rows="8"
/>
</CardContent>
</Card>
</div>
))}
</Container>
</div>
);
}
Errors would be rendered into <Typography color="secondary" variant="h5">
Additional message representing current progress added:
<div>
{acceptedFiles.length !== encodedFiles.length &&
<div>
<Typography color="secondary" variant="h5">
Processing {acceptedFiles.length - encodedFiles.length} files. Wait a moment ...
</Typography>
<br />
<LinearProgress />
<br />
</div>
}
</div>
acceptedFiles
available immediately after files selection and encodedFiles
would be available slightly later
Each uploaded file would be represented by Card
component which internally would
render preview using resulting base64 encoded string:<img src={file.encoded} alt={file.name} />
On this step, full
App.jsx
could be copied locally from repository
Run full application:
$ yarn start
Browser would open local page http://localhost:3000/.
After selecting some files UI would display results:
Deploy to gitlab pages
To have nice public Url like this: https://base64img.gitlab.io when using GitLab pages
Gitlab account
with base64img
name should be registered and have repository called base64img.gitlab.io
.
This is called User Page
at Gitlab Pages.
Other .gitlab-ci.yml
contents looks lik regular CD pipeline for deploying pages
:
image: node:12
pages:
stage: deploy
script:
- yarn install --frozen-lockfile
- NODE_ENV=production PUBLIC_URL="https://base64img.gitlab.io/" yarn build
- rm -rf public/
- mv build/ public/
artifacts:
paths:
- public
only:
- master
CD steps:
-
Install yarn dependencies based on
yarn.lock
(because of--frozen-lockfile
) -
Execute
yarn build
which would invokereact-scripts
and prepare production build of React Application. Important here to providePUBLIC_URL
. -
Gitlab pages require to have contents in
public
folder, so afteryarn build
, removepublic
folder with source files. And move build artifacts frombuild
intopublic
Check more info about Gitlab Pages.
Conclusion
Building SPA’a nowadays is pretty simple with create-react-app
. It does setup all necessary pipelines
for transpiling typescript
and react
code into javascript.
For styling React UI, material-ui
is my first choice due to it’s simple interface and good quality documentation.
As an advantage I would mention included CSS-in-JS solution
, so no scss,less,css
separate files needed at all.
Regarding SPA functionality, react-dropzone
is a proven choice with simple interface and many useful
options to tweak UI and internal functionality.
When developers should write less code, less errors could potentially be introduced
And as usually Gitlab pages
is a good place to host SPA’s with build pipelines.