Managing 400 AI-Generated Puzzle Images: A Pool Gallery Admin for the Webapp


The puzzle pool now holds up to 400 AI-generated images — 40 prompts across 10 stations. As it fills in organically from real plays, we started hitting a problem: how do you know which ones look good and which ones the model botched?

You can’t. Not without looking at them.

And once you find a bad one, how do you fix it? Previously: open MongoDB Compass, find the document, delete it, wait for the next player to regenerate it via the normal pool-miss path, hope it comes out better. That’s miserable.

We needed an admin UI. Not a great one — an internal tool that lets us browse the pool, see what’s generated vs. missing, and replace individual images without writing a script every time.

Where It Lives

First decision: which server does this run in?

We already have an admin UI in the ai-service at localhost:3002/admin — it handles branding assets, model comparison, and category theme images. The puzzle pool could have gone there.

It didn’t, for one reason: the pool is app-specific data. The PuzzlePool MongoDB collection, the PROMPT_BANK, the station IDs — none of that means anything to the ai-service. The ai-service serves multiple apps (or will eventually). It shouldn’t know about Frozen, Rainbows & Unicorns, or Paw Patrol puzzle slots.

So the admin goes in the webapp server, at /api/admin/puzzle-pool for the API and served from server/src/admin/public/ as static files.

Same technology choice as the ai-service admin: plain HTML, CSS, and vanilla JS. No build step, no React, no Vite. It ships in the same Docker image as the API. The server just maps the /admin path prefix to the static files directory.

The Backend: Three Routes

// server/src/routes/adminPool.ts

GET  /api/admin/puzzle-pool
POST /api/admin/puzzle-pool/:id/regenerate
POST /api/admin/puzzle-pool/:id/confirm

GET /api/admin/puzzle-pool is the main data load. It reads all PuzzlePool documents from MongoDB, groups them by stationId, and for each station it computes:

  • The generated entries (with their prompt name and subject from the PROMPT_BANK)
  • The missing slots — indices that don’t have a pool entry yet

The response tells the frontend everything it needs to render the full gallery, including placeholder info for ungenerated slots:

const missing = Array.from({ length: totalSlots }, (_, i) => i)
  .filter((i) => !generatedIndices.has(i))
  .map((i) => {
    const promptEntry = getPromptEntry(stationId, i);
    return {
      promptIndex: i,
      promptName: promptEntry?.name ?? `Slot ${i}`,
      promptSubject: promptEntry?.subject ?? "",
    };
  });

That promptName and promptSubject matter — we want to show “Polar Bear Slide” on an empty placeholder, not just “Slot 0”. It makes it obvious what’s missing and what should be there.

Two-Step Regeneration

This is the design choice I’m happiest about.

The regenerate flow is split into two explicit steps:

  1. Generate Preview — calls /regenerate, which generates an image via the AI service and returns a base64 result. Nothing is written. No S3 upload. No DB change. The image lives only in the browser’s memory.

  2. Confirm Replace — calls /confirm with the base64 image, which uploads to S3 and updates the MongoDB document.

The panel shows the current image and the preview side by side before you commit. If the preview looks worse, you hit “Discard” (or close the panel), and nothing changed. If it looks good, you click “Confirm Replace” and the pool is updated.

export async function previewRegenerate(req: Request, id: string): Promise<Response> {
  // ... validate key, find doc ...
  const result = await generateImage(prompt, model);
  return Response.json({ success: true, data: { previewBase64: result.result } });
  // ← no S3, no DB write
}

export async function confirmRegenerate(req: Request, id: string): Promise<Response> {
  // ... validate key, find doc ...
  const imageUrl = await uploadPuzzleImage(doc.stationId, body.imageBase64);
  doc.imageUrl = imageUrl;
  doc.generatedAt = new Date();
  await doc.save();
  // ← this is the destructive write
}

The separation makes sense because generating is cheap and reversible — it just burns a few seconds and maybe a fraction of a credit. Confirming is the write operation that changes what every future player sees. Those two operations shouldn’t be collapsed into one button press.

On the frontend, the button states make the flow explicit. After generating, “Confirm Replace” and “Discard” appear. Before generating, they’re hidden. You can’t accidentally confirm without first previewing.

Model Picker

The panel has a model selector with two provider groups:

<optgroup label="Replicate">
  <option value="flux-1.1-pro">FLUX 1.1 Pro — Best quality</option>
  <option value="flux-schnell" selected>FLUX Schnell — Fast</option>
  <option value="flux-dev">FLUX Dev — High quality</option>
</optgroup>
<optgroup label="HuggingFace">
  <option value="black-forest-labs/FLUX.1-schnell">FLUX.1-schnell — Fast</option>
  <option value="black-forest-labs/FLUX.1-dev">FLUX.1-dev — Higher quality</option>
</optgroup>

The model name is passed directly to the ai-service’s /api/v1/image/generate endpoint. The ai-service’s provider registry auto-routes based on the value: Replicate short names (flux-1.1-pro, flux-schnell, flux-dev) go to Replicate; fully-qualified HuggingFace model IDs go to HuggingFace. The admin UI doesn’t need to know about provider switching — it just forwards whatever model the user selected.

For normal pool operations, FLUX Schnell is the default — fast enough that regenerating isn’t painful, quality is fine for a 3x3 puzzle tile. For a slot where the model keeps producing something off, FLUX 1.1 Pro on Replicate gives noticeably better results.

The sidebar lists every station with a generated / total count — at a glance you can see that frozen is at 10/40 and rainbows is at 0/40. Click a station and the main panel shows a grid of every slot.

Generated slots show a thumbnail, the prompt name (“Polar Bear Slide”, “Ice Palace Dawn”), and an index badge. Hover reveals a ”↺ Regenerate” button. Empty slots show as dashed placeholders with the prompt name — so you know what should be there, not just that something is missing.

One rule the DOM rendering follows throughout: no innerHTML. Every server value goes in via textContent or setAttribute. The prompt names, station IDs, and image URLs come from MongoDB — treating them as trusted HTML would be a mistake. It’s a simple habit that prevents an entire class of bugs.

const nameEl = document.createElement('div');
nameEl.className = 'card-name';
nameEl.textContent = entry.promptName;  // not innerHTML

Auth

Same pattern as the ai-service admin: enter the x-api-key to access the panel. The key is stored in sessionStorage (gone when you close the tab). On the first login attempt, the UI calls GET /api/admin/puzzle-pool — if the key is valid, the response comes back and the gallery loads. If not, an error message appears.

No separate user accounts. No additional infrastructure. The admin UI is only reachable from inside the cluster in production anyway — the webapp service has an ingress, but these internal routes aren’t publicly documented and require the API key regardless.

The Prompt Editing Flow

When you open the regenerate panel for an existing slot, the prompt field pre-fills with the slot’s promptSubject combined with our standard style suffix:

Polar Bear Slide, cute 3D cartoon illustration for kids, Pixar style, soft lighting, vibrant saturated colors, rounded shapes, detailed, playful, clean composition, no text

The style suffix is hardcoded in the backend — the STYLE constant in adminPool.ts. The promptSubject part comes from the PROMPT_BANK. You can edit the full prompt in the textarea before generating, which is useful when the model keeps misinterpreting something specific.

The edited prompt goes to the backend with the regenerate request, not just the ID. If you tweak the prompt and confirm the result, the image in S3 and MongoDB reflects that new prompt — but the PROMPT_BANK source of truth doesn’t change. The admin is for fixing bad outputs, not redefining what the slot is supposed to be.

What It Solved

Before this, catching a bad image required either a player report or stumbling across it while playing. Fixing it required a manual DB operation. Now we can open the gallery, scan through every generated image for a station in about 30 seconds, and regenerate any bad ones directly.

The pool is still filling in as real players hit new slots. The admin just gives us visibility and control over what’s already there — and a way to fix the occasional AI hallucination before more kids see it.