niels segers
cover

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:

  1. Allow instant preview
  2. Store images temporarily
  3. Clean up unused images
  4. 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:

  1. 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.

  1. The upload key is stored in localStorage, creating a record of temporary uploads per file.
  2. 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.