Project Apollo: Rebuilt After 13 Years

Project Apollo: Rebuilt After 13 Years

Project Apollo started in 2012 as a static HTML page. One file, hard-coded paths pointing to audio and image assets in folders, served directly by Apache. No CMS, no database, no deployment pipeline. It worked, and it kept working untouched for over a decade.

Last month I rewrote the entire thing from scratch. The UI was redesigned and built with Claude Design and Claude Code. The architecture was rebuilt properly — a headless CMS, Docker Compose, a reverse proxy, and a deployment setup that can be reproduced on a fresh server in under ten minutes.

The core idea stayed the same: a hand-curated, editorial music experience. Weekly playlist drops, album art front and centre, no algorithm, no shuffle-everything streaming service behaviour. Just a list of songs someone picked for you.

Two Apps, One Codebase

The rewrite also solved a problem the original never had to consider. I needed two deployments of the same player for two different audiences: Project Apollo at apollo.maya329.com for English listeners, and Maomao Radio at music.maomao329.com for a Chinese-language audience.

The approach: one Git repository, two Docker Compose stacks on the same server, each with its own Directus instance and PostgreSQL database. The front-end code is identical across both. Feature differences between the two — including a Mandarin pinyin toggle that only exists on Maomao Radio — are controlled entirely through CMS settings, not code branches.

This means both apps always run the same version of the code. Bug fixes and new features deploy to both simultaneously. There is no Apollo-branch or Maomao-branch to maintain. Feature divergence lives in configuration, not in the codebase.

The Stack

Three Docker Compose services handle everything:

  • Caddy — reverse proxy and static file server
  • Directus — headless CMS, managing playlists, songs, files, and settings
  • PostgreSQL 15 — backing Directus
Browser
  └── Cloudflare Tunnel → Server
        └── Caddy :80
              ├── /cms/*  → Directus :8055
              └── /*      → /srv  (static SPA files)

Routing the CMS API through /cms on the same origin as the front-end eliminates CORS entirely. Directus is never exposed directly to the public — all traffic passes through Caddy, which strips the prefix and proxies the request.

The front-end files are baked into the Caddy Docker image at build time, copied directly into /srv. No volume mounts. A code deploy is:

docker compose down && git pull && docker compose up -d --build

Either the image has the right files or it doesn’t — no sync step, no deployment state to worry about.

No Build Tooling

The entire front-end is four files:

FileLines
index.html~150
js/app.js~892
css/styles.css~1035
css/mediaqueries.css~456

No npm. No webpack. No TypeScript. No build step of any kind. The files are written, versioned, and deployed as-is. Cache-busting is a version query string on JS and CSS files — ?v=20260608f — bumped manually on every change. Crude but completely explicit. No fingerprinting, no hash computation, no CI pipeline needed to know what version is deployed.

The only runtime dependency is Phosphor Icons, loaded via a deferred script tag from a CDN. The pinyin-pro library for Maomao’s pinyin toggle is loaded lazily via dynamic import only on the Maomao deployment, only when the user opens the lyrics panel for the first time.

Directus as a Content Layer

Directus manages playlists, songs, album art, audio files, and lyrics. It is consumed read-only by the front-end. There is no custom server-side logic — all business rules are expressed as Directus query parameters.

On load, the front-end makes a single API call that fetches the entire playlist tree: playlists, nested songs, file references, metadata, and streaming links, all in one round trip. Only published songs are included — the draft filter runs server-side, so unpublished content never reaches the client.

The data model is straightforward:

  • playlists — week label, start date, nested songs array
  • songs — title, artist, album, album art (file reference), accent colour (hex), audio file, lyrics, LRC file, Spotify and Apple Music links, sort order, status
  • settings — CMS-controlled labels, placeholders, and feature flags including the pinyin toggle switch

One detail worth noting on LRC files: the .lrc format has no registered IANA MIME type. Directus’s content-type filter would silently block uploads. Setting the allowed MIME types to an empty array on that field bypasses the restriction.

Deployment with Make

bash

make deploy-prd

That runs two SSH commands in parallel — one for each deployment on the server — both executing:

docker compose down && git pull && docker compose up -d --build

Both apps rebuild and restart from a single command on the local machine.

Schema changes to Directus use a separate make schema-apply step, which copies directus-schema.yaml into the running container and calls directus schema apply. Folder UUIDs in Directus are hard-coded and inserted once at setup time so that schema field references resolve without manual patching after a fresh install. If you’ve read the Makefile article, this is exactly the kind of workflow a Makefile is built for.

Dynamic Accent Colour

Each song carries an album colour hex field set by the editor. On every track change, three CSS custom properties are derived from that value and applied to the document root — an accent colour, a muted version, and a background tint. The entire UI shifts palette with each song.

The colour derivation is done in JavaScript by converting the hex to HSL, clamping lightness to a readable range, and generating the variants programmatically. No colour picker needed in the CMS — the editor just picks a hex code from the album art.

The Pinyin Toggle

Maomao Radio’s audience may not read Chinese characters. The lyrics panel includes a toggle that converts displayed lyrics to romanised Mandarin pinyin on demand.

pinyin-pro v3 handles the conversion. It is ~200KB and completely irrelevant to the Apollo deployment, so it is never loaded there. On Maomao, applySettings() detects the pinyin flag and pre-fetches the library before the user opens the lyrics panel, so the first tap on the toggle feels instant.

The library dropped its UMD bundle in version 3. Loading it as a script tag from a CDN returns a 404 HTML page which the browser refuses to execute as JavaScript. Dynamic import via esm.sh resolves the package’s ESM entry point correctly. The import promise is cached so subsequent calls never trigger a second network request.

One custom character mapping was required: 妳 (U+5A73, traditional Chinese female “you”) is missing from the library’s dictionary and defaults to the wrong reading. A customPinyin({ '妳': 'nǐ' }) call at library-load time fixes it.

iOS Safari

Three bugs specific to iOS Safari took time to solve.

scrollTo({ behavior: 'smooth' }) inside a fixed-position container is silently ignored — replaced with a manual requestAnimationFrame scroll animation. getBoundingClientRect() returns stale coordinates inside fixed elements — replaced with offsetTop. And overflow: hidden on the body blocks all touch scrolling including inside nested panels — replaced with overflow: auto and overscroll-behavior: contain on the panel itself.

Brave on iPhone has a separate issue where the animated bottom toolbar collapses the viewport height enough to briefly trigger the landscape layout in CSS while the device is physically portrait. Fixed by gating the landscape styles on three media query conditions simultaneously: orientation: landscape combined with max-height: 430px and min-width: 500px.

From 2012 to 2026

The original was as minimal as a web project gets. One HTML file, one JS file with hard-coded paths, assets in folders, Apache serving the lot. It ran for thirteen years without needing to change.

The rewrite was not prompted by anything breaking. It was about wanting a proper foundation — a CMS so playlists could be managed without editing code, a UI that felt considered, and a deployment that could be reproduced from scratch on a new machine. Claude Design handled the UI iteration; Claude Code handled the implementation work.

Thirteen years between versions. The idea behind the project — a hand-curated playlist, presented as an experience rather than a library — is exactly the same.

Leave a comment:

Top