The admin UI is a product too. Even though I am the only user, a buggy editing experience slows down content creation and erodes confidence in the tools. Two issues had been nagging me since the CMS build: a ghost text rendering bug in the markdown editor, and a missing download button in the media gallery.
The symptom was strange. When editing a blog post, the text in the markdown editor appeared doubled — a faint, slightly offset copy of every character visible beneath the main text. The cursor position did not match where it appeared to be. Selecting text highlighted unexpected ranges. The whole editing experience felt broken.
The @uiw/react-md-editor component uses a two-layer rendering approach:
<pre><code> layer — the visible, syntax-highlighted text. This is what you see and read.<textarea> layer — an invisible textarea positioned exactly on top of the code layer. This captures your keystrokes and handles text input.The textarea is made invisible using -webkit-text-fill-color: transparent. You type into it, but you see the formatted output from the <pre><code> layer below. It is a clever trick that gives you a rich editing experience with native textarea input handling.
My PostForm.tsx component had a useEffect that forced colour values onto editor elements to match the site's dark/light theme. The selector was:
// The bug
container.querySelectorAll<HTMLElement>(
'.w-md-editor-text-input, .w-md-editor-text-pre, .w-md-editor-text-pre > code'
).forEach(el => {
el.style.setProperty('color', foregroundColor, 'important');
});
The problem is .w-md-editor-text-input — that is the invisible textarea. By setting color: #ffffff !important on it, I overrode the -webkit-text-fill-color: transparent that kept it invisible. Suddenly both layers were rendering visible text, creating the ghosting effect.
Remove the textarea from the colour-forcing selector. Only apply colour to the visible layers:
// The fix
container.querySelectorAll<HTMLElement>(
'.w-md-editor-text-pre, .w-md-editor-text-pre > code'
).forEach(el => {
el.style.setProperty('color', foregroundColor, 'important');
});
One selector removed. The textarea goes back to being transparent. The ghost text disappears. The cursor aligns correctly. The editing experience is clean again.
When working with components that use layered rendering (editors, syntax highlighters, virtual scrollers), be extremely careful about applying global style overrides. The invisible layers are invisible for a reason. A well-intentioned theme adjustment can break the entire visual model.
The media gallery in the admin UI displayed uploaded images and PDFs with "Insert" and "Copy tag" buttons. But there was no way to download a file directly — if I wanted to check an image or share a PDF, I had to construct the URL manually or open the S3 console.
I added a "Download" button alongside the existing actions for each media item. The button uses the CDN URL (via getMediaUrl()) and opens the file in a new tab:
<button
onClick={() => {
const url = getMediaUrl(item.key);
window.open(url, '_blank');
}}
className={styles.downloadBtn}
>
Download
</button>
The button appears in the card actions for both images and PDFs. It is styled consistently with the existing "Insert" and "Copy tag" buttons — same size, same spacing, same hover behaviour. No visual disruption to the existing layout.
For images, the download opens the full-resolution file in a new tab (the browser's native "Save Image As" handles the actual download). For PDFs, it opens the PDF viewer in a new tab where the user can download via the browser's built-in PDF controls.
These two fixes — the ghost text bug and the missing download button — are not glamorous features. They will never appear in a feature announcement or a product demo. But they represent something important about building software: the quality of the tools determines the quality of the output.
A broken editor means I hesitate to write. A missing download button means I waste time constructing URLs. These small frictions accumulate into reluctance, and reluctance means fewer blog posts and less content.
Fixing them took less than an hour combined. The return on that investment — in reduced friction and increased confidence — will pay off every time I open the admin UI.
If you are reading this blog, the content was written in an editor that renders cleanly, with a cursor that goes where expected, using media that was managed through a gallery with proper upload, insert, and download capabilities. The admin experience is polished, which means I can focus on writing rather than fighting my tools.
This is the eighth and final post in the development journey series for this portfolio site. From a blank Next.js project to an AI-powered portfolio with a full CMS, REST API, media pipeline, and polished admin UI — every feature was built from scratch, documented in plans, and implemented incrementally.
The series covered:
Each post documents a real implementation decision, the skills required, and what it means for the end product. The portfolio is not just a showcase of projects — the portfolio itself is the project.
This post is part of a series documenting the development of arkadiuszkulpa.co.uk.