Inskribe: Smart image handling from upload to commit
Nov 21, 2024
inskri.be is a simple documentation tool that lets you write Markdown directly in your GitHub repositories. No complex setup, no configuration - just authenticate and start writing. While building it, one of the interesting challenges I faced was handling image uploads efficiently.
The Challenge
When users are writing documentation, they often need to upload images. However, we don't want to commit these images to GitHub immediately - what if the user discards their changes? We needed a temporary storage solution that would:
- Allow instant preview
- Store images temporarily
- Clean up unused images
- Commit only when the user is ready
The Solution
I implemented a clever two-stage process using uploadthing for temporary storage and GitHub's API for permanent storage.
I hear you -- why not just use localStorage
for temporary storage? Well since localStorage
has a max storage size of 5MB per domain, it would become obsolete rather quick. But what about IndexedDB
?
Well... that could theoretically work. But since we're using Next.js to build Inskribe, sending multiple large images from the client to server actions is never a great idea. So quite pleased with the solution I came up with.
If you feel like there's other ways I should've handled this, send me a message over at Bluesky! I'm open to learn more about it.
Here's how it works:
- When a user drops an image, it's immediately uploaded to uploadthing:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
const { track } = useRecord(file); const { startUpload, routeConfig } = useUploadThing("imageUploader", { onUploadBegin: () => { setIsUploading(true); }, onClientUploadComplete: (data) => { track(data[0].key); setIsUploading(false); setFiles([]); }, onUploadError: (err) => { setError(err.message); setIsUploading(false); }, });
useRecord
here is just a hook around a zustand store.
- The upload key is stored in
localStorage
, creating a record of temporary uploads per file. - When the user commits their changes, the system:
- Downloads the images from uploadthing
- Creates Git blobs for each image
- Updates the markdown to reference the new paths (inside new root directory
.inskribe/
) - Creates a single commit with both the markdown and images
- Cleans up the temporary uploads
The cleanup process is particularly simple as we can just clear the records depending on the situation. Restoring a single file because the origin changed? Clean up that file. Committing your changes? Clear full record of all files.
1 2 3 4 5 6 7 8 9
async function cleanupUploadthingImages(keys: string[]) { const utapi = new UTApi(); try { await utapi.deleteFiles(keys); } catch (error) { // do something with error } }
This approach has several benefits:
- Users get instant feedback with image previews
- No orphaned images in the repository if changes are discarded
- Single atomic commit for both content and images
- Automatic cleanup of temporary storage
- Works seamlessly with private repositories
The end result is a smooth editing experience where users don't have to think about the complexity of image handling - they just drag, drop, and commit when ready. The complexity is hidden away, but the implementation ensures efficient resource usage and clean repository history.