🔑 Key Insights
✦ AI·GENStarting from one small need—Monaco only takes links, not inline images—this post unwinds the whole rabbit hole of image handling: why Base64 both chokes the editor and bloats the database, the design for auto-uploading on paste to a NAS, and the easiest landmine to miss—a Docker bind mount is really a "teleporter," and if the HDD isn't mounted, files silently land back on the system disk. It also covers the plan to rewrite a static photo wall into a NAS-backed dynamic API, and the "UI first, data later" trade-off during the gallery redesign.
Lately I started using Monaco Editor as my blog's admin editor. It's the core of VS Code — an editor built for writing code — and here I am writing blog posts in it, which is a little hardcore when you think about it. But the star of this post isn't Monaco itself; it's the seemingly trivial act of "getting an image into it." That one simple need ended up pulling out a whole chain of pitfalls: Base64, background uploads, NAS mounts, and rewriting the photo wall.
Monaco Only Eats Links, Not Images#
Monaco lives in a completely different world from WYSIWYG editors. It's not like Word or a editor where you drag an image in and see it instantly — in its world, an image is just a string of Markdown link. Which means: to insert an image, you first have to turn it into a usable URL. At the time, only two roads came to mind: embed the image as directly, or upload it somewhere to get a URL.
The first instinct was Base64. The image becomes one long string embedded straight into the Markdown, so text and image are bound together — move the post around and you never lose the picture. Sounds convenient:
But testing it, it's a deep pit. A normal image turns into hundreds of thousands of characters in Base64, and the moment Monaco hits a giant single-line string like that, rendering lags until you question your life choices — never mind that shoving that huge blob straight into the database tanks query performance and balloons storage. This road clearly leads nowhere.
So back to the industry standard: upload to the server for a URL, then reference it in Markdown. The article text stays lightweight, the database only stores text, and images are served by the filesystem, NAS, or a CDN — fast to load and easy to cache:
TIP
Base64 is "the most tempting one-line fix" — text and image welded together, zero dependencies, copy-and-go. But it hides the cost where you can't see it: the editor's render performance, and the database's size and queries. A lot of "simple" solutions just defer the bill and file it somewhere else.
Making "Upload" Vanish From the Writing Flow#
Direction settled — but manually uploading an image, copying the link, and pasting it back into the editor breaks the writing flow far too much. The ideal: whether you paste or drag, the image auto-uploads, gets a link, and drops in at the cursor, with the whole process "invisible." The logic is actually straightforward, in four steps:
- Listen for events: hook Monaco's .
- Intercept images: check whether the clipboard or dragged content contains a — let plain text through, but intercept the default behavior for an image.
- Upload in the background: hand the image to the backend at
POST /api/upload, which writes the file and returns a URL. - Insert at the cursor: the frontend takes the URL and uses to insert
right where the cursor is.
In code, it looks roughly like this (this was the design sketch at the time):
// On paste, intercept the image, upload in the background, insert the URL at the cursor
editor.onDidPaste(async () => {
const file = getImageFromClipboard(); // Is there a File Object on the clipboard?
if (!file) return; // Plain text passes through
const url = await uploadToNAS(file); // POST /api/upload → returns the image URL
editor.executeEdits('paste-image', [{ // Insert markdown at the cursor
range: editor.getSelection(),
text: ``,
}]);
});This upload path did actually get built: the backend auto-compresses and converts to WebP, stores into the NAS, and returns a URL of the form /uploads/2026/02/<timestamp>-<random>.webp — date-based folders, timestamp filenames. From then on, pasting a screenshot is nearly instant, and the writing flow never breaks.
But Won't the Files Secretly Stay on the System Disk?#
Upload sorted — but here I paused on a very natural question: my server runs on the system disk, so even if the file ends up on the NAS, doesn't it still leave a copy on the web side (the SSD system drive) as it passes through the Node.js backend?
The answer is no, and the key is the . I make a dedicated directory on the NAS drive and mount it into the container:
services:
blog-backend:
volumes:
# left is the real HDD path, right is the path inside the container
- /mnt/hdd16tb_01/blog_data/images:/app/public/uploadsNow Node.js just writes the image to /app/public/uploads, and the file passes through that mapping and lands directly on the NAS sectors. A is essentially a "teleporter": anything written under /mnt/hdd16tb_01 doesn't consume a single KB of the system disk — the system drive is just a "passing corridor" in the whole process. (By contrast, if you let Docker create a named volume, the files sit under /var/lib/docker/volumes/ — and that's what eats your system disk.)
But this teleporter has exactly one failure mode: if that HDD isn't actually mounted, Linux treats /mnt/hdd16tb_01 as an ordinary empty folder, and files written there silently land back on the system disk — no error, you won't even notice, until one day the system disk is mysteriously full. So every time, you check the mount with df -h:
Filesystem Size Used Avail Use% Mounted on
/dev/nvme0n1p2 1.9T 255G 1.5T 15% /
/dev/sdb1 15T 28K 15T 1% /mnt/hdd16tb_02
/dev/sda1 15T 22G 15T 1% /mnt/hdd16tb_01Seeing /mnt/hdd16tb_01 and /mnt/hdd16tb_02 mapped to the two 15T HDDs (and not folded back into /) is what lets me write images into them with peace of mind. Finally, nginx maps requests starting with /uploads/ straight to the images on the disk (this was the plan at the time):
location /uploads/ {
alias /mnt/hdd16tb_01/blog_data/images/;
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}WARNING
The nastiest failure of "store files on the NAS" isn't an error — it's the absence of one. If the mount drops or the path is wrong, the system happily writes the files onto the system disk, everything looks fine, right up until the system disk blows. For any storage that relies on a mount point, letting df -h become muscle memory is worth it.
Photos From My Phone — How Do I Reference Them?#
Screenshot uploads were handled, but there's another scenario: I'm out somewhere shooting photos on my phone, and after they land on the NAS, how do I reference them in a post? Here I weighed two approaches:
Approach 1, an nginx static proxy. Expose some photo folder on the NAS as a public URL via nginx, so anything dropped in there is reachable at https://koimsurai.com/nas-images/filename.jpg. The upside is almost zero development — a photo becomes referenceable the instant it hits the NAS; the downside is just as obvious: you have to remember or copy the filename from the NAS yourself, and once there are lots of photos, hunting one down hurts and the writing flow breaks.
Approach 2, a gallery picker (the upgrade I wanted to build). The NAS exposes a GET /api/photos returning JSON of every image's filename and URL under a folder; then above the Monaco editor, an "Insert image from NAS" button pops a Modal of a thumbnail grid, and clicking one auto-inserts  at the cursor. No more copying URLs by hand — you pick visually.
Simple tool, very different experience. Approach 1 works as a stopgap; Approach 2 is the target form in my head — but at that moment, it was still parked at "planned."
Should I Rewrite the Existing Photo Wall?#
Following this thread, I looked at my existing photo wall. Back then it was a very typical "static build": the frontend read a hardcoded /photos-manifest.json via manifestLoader.ts, listing each image's path, thumbnail, and placeholder data; and if it couldn't even find the manifest, it fell back to Vite's import.meta.glob to brute-read images hardcoded into the frontend project's assets/Portfolio/. I'd also written an image-processor.ts that auto-compresses, converts, and computes the thumbHash for Blurhash — but its output path was hardcoded to a static web folder like /generated/.
The biggest pain point: adding a single photo was absurdly tedious. For every image, I had to drop the file into the project's static directory, run a script to regenerate photos-manifest.json, and then redeploy the whole site. Even for one more cat photo. Photography also eats space, and cramming it onto the 1.9T system disk wasn't sustainable either.
So I sketched out a rewrite (again, the plan at the time):
- Turn
image-processor.tsinto a NAS background service — the moment a photo lands on the NAS, the background auto-generates the high-res image, the thumbnail, and thethumbHash, only now with the output path changed to an nginx-proxied URL. - Build a
GET /api/gallery/photos— returning a JSON array with exactly the same structure as the originalPhotosManifestData. - A painless frontend swap —
PhotoGallery.tsxonly changes one line of data source:
const response = await fetch('/photos-manifest.json');
const response = await fetch('https://koimsurai.com/api/gallery/photos');Because the returned data structure is identical, the frontend's , Blurhash placeholders, and hover-interaction components don't change a single line — only the data source moves from "Web project local" to "NAS dynamic API." After the rewrite, adding a photo no longer needs a redeploy: drop it into the NAS and it shows up on its own.
UI First, Data Later#
The photo wall's visuals, though, I redid first. The old version had cards too far apart and pressed a big "Photography Portfolio" title card on top, so it looked more like a "folder management UI" than a gallery showing off visuals. Taking cues from Innei's Afilmory, the core is to make the photo the only protagonist: ditch big squares for an equal-height justified grid, center and tighten the title, and on hover dim the neighboring photos so only the current one floats up. Redone, it looks like this:
And then, with the pretty wall finished, one thing hit me: my photos have no tags at all. That row of "All / Cats / Landscapes" filter buttons up top can't actually filter anything.
One Thread That Pulls Out a Whole Wall#
Looking back, this whole trip started from "Monaco only eats links" and pulled out the Base64 trap, the teleporter and df -h, and then the photo-wall rewrite. Drawing the upload path that's actually up and running, it looks roughly like this:
Each piece of technical detail looks isolated on its own: Monaco's events, Base64 encoding, one line of bind mount, one df -h. But they all serve the same thing — making "put an image in" feel natural, even imperceptible, for the person writing. And the most fun part of this trip was the most humble lesson: a lot of seemingly arcane problems (why did the file end up on the system disk?) have answers hiding in the next step of "first, actually understand how it works." A mount point is a teleporter — as long as you remember to confirm it's really connected.
No comments yet
✨ Be the first to comment