How to build image upload with draggable reorder, rotation and delete in next.js/react and python backend
This howto covers:
- image upload with
<input type="file" multiple />
component - frontend in next.js (97% is react, so if you’re using react this howto should work for you too)
- backend in python: uploading to s3 (I’m using Flask but 97% is framework agnostic)
- image reordering with dndkit
- image deletion
- image rotation in python using Pillow
Uploading images
There’s a popular library called Dropzone which offers a react library but I never really saw the point of a “drag and drop landing zone”. I’m an old fashioned “click this button to upload” guy.
The following code shows a button, when the button is clicked it opens the file dialogue which lets the user choose multiple files but only those of type image:
import { PhotoIcon } from "@heroicons/react/20/solid";
import { useRef } from "react";
export default function Example() {
const inputFileRef = useRef();
return (
<div className='m-5'>
<input id="file_upload" type="file" multiple
accept="image/*" className='hidden'
ref={inputFileRef}
onChange={onImageUpload}
/>
<button
type="button"
onClick={() => inputFileRef.current.click()}
className="inline-flex items-center gap-x-2 rounded-md
bg-indigo-600 py-2 px-3 text-sm font-semibold text-white">
<PhotoIcon className="-ml-0.5 h-5 w-5" aria-hidden="true" />
Upload images
</button>
</div>
)
}
I’m using tailwindCSS and the PhotoIcon
is from heroicons (npm i @heroicons/react
), but of course you can adapt this to your liking.
Next is of course to add the code for uploading the images. On javascript side that’s what I use:
const [isUploading, setIsUploading] = useState(false)
const [images, setImages] = useState([])
const onImageUpload = async (e) => {
const files = e.target.files;
const data = new FormData()
for (var i = 0; i < files.length; i++) {
data.append(`file-${i}`, files[i], files[i].name);
}
setIsUploading(true)
const res = await fetchJson('/api/upload', {
method: 'POST',
body: data,
})
setImages(images.concat(res.filenames))
setIsUploading(false)
}
Some explanations:
- Line 1,9,15: I want to let the user know a file is uploading -> I’m using
isUploading
for this - Line 6: granted, that’s an ugly way to loop over the images. You might know a nicer way, but this was good enough for me
- Line 10:
fetchJson
is a helper function I use which adds servername, error handling etc. I’m leaving this out as I’m assuming you have a similar function already - Line 14: the backend will return an array of filenames with paths which I’m saving in the
images
variable
This is all very much straight forward! So really no need to use any libraries for this. Let’s have a look at the backend code. I’m using flask but the code is pretty much the same in any framework:
from flask import request, jsonify
import boto3
import shortuuid
id_generator = shortuuid.ShortUUID()
@app.route('/api/upload', methods=['POST'])
def upload():
files = list(request.files.items())
s3 = boto3.client('s3')
bucket = 'my-s3-bucket'
path = f'upload'
paths = []
for _,file in files:
ending = file.filename.split('.')[-1]
if ending in ['jpg', 'jpeg']:
content_type = 'image/jpeg'
else:
content_type = f'image/{ending}'
fileid = id_generator.random(length=10)
filename = f'{path}/{fileid}.{ending}'
s3.upload_fileobj(file.stream, bucket, filename,
ExtraArgs={"ContentType": content_type})
paths.append(filename)
return jsonify(dict(filenames=paths))
Again, some explanations:
- Line 20: in order to have no filename clashes on s3, I’m generating a random 10 length string, containing upper+lowercase letters and numbers. That’s 10^17 possible strings, pretty safe I’d say. You’d need to
pip install shortuuid
to use this - Line 16-19: In order that the browsers correctly show the images from s3, you need to specify the mime type on upload. I guess there are better ways to do this, but this code has done the trick for me so far
- Line 22: note that the file is uploaded from memory, no messing around with temporary files
If you care for speed, you’d want to run this with multi-threading or multi-processing, but for my case it seemed fast enough.
What’s missing on the client side is the loading animation: After Upload images
paste this code:
Upload images
{isUploading && (
<LoadingAnimation className="fill-white w-6 h-6" />
)}
If you want to use my LoadingAnimation
then copy-paste this code into a separate module.
Show images and make them draggable
I used hours to find a good library which supports drag and drop for react. Every library was either no longer supported (react-beautiful-dnd) or looked complicated to use (react-dnd), I ended up using dnd kit, the »new kit on the block«. It doesn’t yet show up on the first page of google search, but has already 7.2K stars on github at the time of this blogpost (March 2023).
I was unsure if mobile was supported, as their doc is not clear about this part (and their example on their homepage does not work with mobile), but rest assured, the code covered in this howto works on mobile too!
To install it:
npm install @dnd-kit/core @dnd-kit/sortable
The following example is mainly taken from the official documentation.
First, you’d need these additional imports:
import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { arrayMove, SortableContext, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import Image from "next/image";
If you’re using react, you can omit the Image
import and you can just <img>
later.
Then, you’d need those two additional functions
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event) => {
const { active, over } = event;
if (active.id !== over.id) {
setImages((images) => {
const oldIndex = images.indexOf(active.id);
const newIndex = images.indexOf(over.id);
return arrayMove(images, oldIndex, newIndex);
});
}
}
Sensors
sensors
defines with what you can move the images:
- a pointer sensor is needed for moving the images with a mouse (on desktop) or touch (on mobile). I added
activationContraint
to be able to click on elements (delete, rotate) later without the drag operation to start already - a keyboard sensor lets you move the items using tab, space, left/right control. Not super intuitive, but fancy nonetheless
handleDragEnd
The handleDragEnd
performs the switch on the images array. So the move is actually reflected on the array which you can store in a database later to store the order.
Finally the html part. Put this above the <input>
element:
<DndContext sensors={sensors} collisionDetection={closestCenter}
onDragEnd={handleDragEnd}>
<SortableContext items={images}>
<ul role="list" className="grid grid-cols-4 gap-x-4 gap-y-8">
{images.map((file) => (
<li key={file}>
<SortableItem id={file}>
<div className="group w-full overflow-hidden
rounded-lg bg-gray-100">
<div className="aspect-4/3 overflow-hidden relative">
<Image priority fill
src={`https://example.com/${file}`}
sizes="22vw" alt=""
className="object-scale-down />
</div>
</div>
</SortableItem>
</li>
))}
</ul>
</SortableContext>
</DndContext>
You see the code is quite straight forward, that’s what I like about dndkit. The only extra thing you need is top copy-paste SortableItem
into an own component and import this. This is mostly taken from here with the only addition that I added props.children
.
Some explanations about the code:
- Line 1:
collisionDetection
defines when images should move by the side. If you want to play with this, see the docs - Line 10: I created a 4/3 aspect via tailwindcss config (see doc)
- Line 12: I’m serving the image from s3 through cloudfront. This is needed for SSL. If you don’t need SSL, you can serve them directly from S3 once you make the bucket world-readable.
- Line 11-14: Instead of the Image component, img works as well, I had some problems with showing portrait mode images properly (without having them filling the whole canvas). Only
<Image>
solved this issue for me. If you’re a skilled CSS/React person, you can solve this with some additional styling I’m sure.
That’s it already for ordering the images! Congrats on reaching this far. If you don’t need deletion and rotation then you can stop at this point!
Deletion
Delete is quite simple and I was lazy and only implemented the client side 😅
After the closing div after <Image>
, add this code:
<div className="m-2 flex justify-center truncate text-sm font-medium text-gray-900">
<!-- rotation icons -->
<TrashIcon className='h-4 z-20' onClick={handleDelete} img-id={file} />
</div>
The TrashIcon
is taken from heroicons (npm i @heroicons/react
).
The deletion handler:
const handleDelete = (e) => {
const path = e.currentTarget.getAttribute('img-id')
setImages(images.filter(item => item !== path))
}
That’s it 😇. Super sneaky lazy, but I don’t bother doing the deletion on s3. I’ll handle this separately as I’ll need to handle aborted form submissions anyway and will run a daily “delete orphaned images” job on the backend.
Rotation
This is somehow a nice-to-have feature, because you could argue that users can do this on their laptop. Thing is, many have no idea how to do this and handling image rotation in python is fun, so…
First, add the rotate-left and rotate-right icons, I used inline svg for this. This is ugly but straightforward. And by now you probably guess how I roll: If it works, it works 😎.
Copy-paste this gist where the <!-- rotation icons -->
is in the code above. This has a click handler into handleRotateLeft
and handleRotateRight
. Here’s the code for both functions:
const handleRotate = async (path, direction) => {
setIsRotationLoading(path)
const res = await fetchJson(`/api/rotate/${direction}`, {
method: 'POST',
body: JSON.stringify({ path: path }),
headers: {
'Content-Type': 'application/json'
},
})
const images_new = images.map(u => u == path ? res.filename : u);
setImages(images_new)
setIsRotationLoading('')
}
const handleRotateRight = (e) => {
const path = e.currentTarget.getAttribute('img-id')
handleRotate(path, 'right')
}
const handleRotateLeft = (e) => {
const path = e.currentTarget.getAttribute('img-id')
handleRotate(path, 'left')
}
Again some explanations:
- Line 1:
direction
is a string, eitherleft
orright
, an enum would have been cleaner but let’s stay brief here - Line 2,12: as the image rotation takes about half a second, I’m showing a little loading animation under the image which is rotating. I’m leaving the html part as an exercise for the reader 👨🏫.
- Line 7: If you omit sending the content-type, it will cause strange exceptions in the python backend
- Line 10: The backend will rotate the image and will respond with a new image path. It was easier to change the path, as this way I’m sure the client fetches the image afresh. Replacing the image without changing the path/filename would create lots of caching issues which was not worth to deal with.
And finally the backend code in python.
You’d need to pip install Pillow
which we need to rotate the image.
from flask import request
import boto3
from io import BytesIO
from PIL import Image
def _rotate(orig, rotation):
s3 = boto3.client('s3')
s3_response_object = s3.get_object(Bucket='my-s3-bucket', Key=orig)
object_content = s3_response_object['Body'].read()
b = BytesIO(object_content)
img = Image.open(b)
img2 = img.transpose(rotation)
output = BytesIO()
img2.save(output, format=img.format)
output.seek(0)
ending = orig.split('.')[-1]
if ending in ['jpg', 'jpeg']:
content_type = 'image/jpeg'
else:
content_type = f'image/{ending}'
fileid = id_generator.random(length=10)
path = f'upload'
filename = f'{path}/{fileid}.{ending}'
s3.upload_fileobj(output, 'my-s3-bucket', filename,
ExtraArgs={"ContentType": content_type})
return jsonify(dict(filename=filename))
@app.route('/api/rotate/right', methods=['POST'])
def rotate_right():
data = request.get_json()
orig = data['path']
return _rotate(orig, Image.ROTATE_90)
@app.route('/api/rotate/left', methods=['POST'])
def rotate_left():
data = request.get_json()
orig = data['path']
return _rotate(orig, Image.ROTATE_270)
Again, some explanations:
- Lines 9-12:
BytesIO
andget_object
avoids messing around with temp files. Temp files are always a hassle as they produce clutter and need to be removed and stuff - Lines 13-16: the rotation also happens in memory.
seek(0)
is needed to put the file pointer to the beginning of the file, otherwiseupload_fileobj
will upload an empty file. - Lines 17-26: see the notes on the backend code for uploading further up of this howto
And that’s it. I hope I didn’t forget anything! If I did, as always: leave a comment, I hope to follow up quickly.
Comments