Skip to main content

Translation admin tool (Nebenprojekt)

A small, separate web app that replaces the Google Sheet. It is the authoring source of truth (keys, context, per-locale values, review status, machine translation) and syncs published translations into the backend text_contents table, which the apps already read. The runtime stays untouched; the admin tool only writes.

This page gives: the exact scaffold commands, a complete ready-to-paste prompt to build it, and how to connect it so the automatic insertion works.

Role in the pipeline

code: t("Save")
│ npm run i18n:extract (baseline + i18n.catalog.json)

i18n:push ──POST /api/ingest──► ADMIN TOOL (own DB)
│ translate (DeepL) + review
│ publish

backend text_contents ──GET /text_contents──► apps (OTA)

The admin tool holds the workflow metadata the backend doesn't (context, namespace, draft/published status, deprecated_since). On publish it writes the final value into the backend per locale, and the apps pick it up on the next launch — no app release.

Tech stack

Same stack as this monorepo so it feels familiar: Next.js 16 (App Router) + React 19 + TypeScript + Tailwind v4, plus Prisma (SQLite to start, swap to Postgres when hosted) and DeepL for machine translation. Unlike the apps it is not a static export — it runs as a normal SSR Next.js app with Route Handlers, so it can own a database and call the backend server-side.

Step 1 — scaffold (terminal)

# Create the project (Next 16 + React 19 + Tailwind v4 + TS + App Router)
npx create-next-app@latest enode-translations \
--typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --no-turbopack

cd enode-translations

# Data layer + machine translation
npm install prisma @prisma/client
npm install zod # request validation
npx prisma init --datasource-provider sqlite

# (optional) initialise git
git init -b main

Then open the project in your editor / agent and paste the prompt below.

Step 2 — ready-to-paste agent prompt

Paste this verbatim into the agent running inside the freshly scaffolded enode-translations project.

You are building Enode Translations, an internal admin tool that manages UI translations for the Enode apps and syncs published values into the Enode backend. The project is a fresh create-next-app (Next.js 16 App Router, React 19, TypeScript, Tailwind v4, src/ dir, @/* alias), with prisma + @prisma/client + zod installed and Prisma initialised for SQLite. Build the whole tool. Keep it simple, typed, and explicit.

Domain model (Prisma schema, prisma/schema.prisma). The unit of translation is a TextContent identified by a stable content-hash key.

  • Language: code (BCP-47, unique, e.g. "en", "de"), nativeName, displayName, enabled (bool), createdAt.
  • TextContent: id (cuid), key (unique), sourceText, context (nullable), namespace, backendId (nullable UUID — the backend text_contents.id once published), deprecatedSince (nullable string), createdAt, updatedAt.
  • Translation: id, textContentId (FK), locale, value, status ("draft" | "published"), updatedAt. Unique on (textContentId, locale). The en translation always equals sourceText.

Run npx prisma migrate dev --name init after writing the schema, and add a src/lib/db.ts exporting a singleton PrismaClient.

Backend client (src/lib/enode.ts). Model it on the Enode web client. Every request sends these headers: api-key (env ENODE_API_KEY), Authorization: Bearer <token> (the admin user's token, see auth below), device-type: Portal, device-name: TranslationsAdmin, device-id (env ENODE_DEVICE_ID or a constant), x-user-agent: EnodeTranslationsAdmin/1.0 (web), accept: application/json, and accept-language: <locale> — the backend selects the locale from this header, there is no ?locale query. Base URL from env ENODE_API_BASE (default https://developapi.enode.ai/api). Implement:

  • login(email, otp): POST /users/login with HTTP Basic auth header Authorization: Basic base64("email:otp") and api-key, returns { token }.
  • requestOtp(email): GET /users/login/otp/{base64(email)}.
  • getAppLanguages(): GET /app_languages.
  • upsertTextContent({ id?, key, value }, locale, token): write a translation value for a locale. Send accept-language: <locale> and body { id, key, value }. If id is absent, create (the backend returns the new row incl. its id); if present, update. (Confirm the exact path/shape against the backend — POST /text_contents to create, PUT /text_contents/{id} to update, mirroring the other write endpoints; the create DTO is { id, value } plus the new key.)

Auth (admin session). A minimal email+OTP login page that calls requestOtp then login, stores the returned bearer token in an httpOnly cookie, and gates the app behind it. All backend writes use this token.

Ingest route (POST /api/ingest). Receives the push payload { sourceLocale: "en", entries: [{ key, text, context, namespace, locations }] }. Authenticate it with a shared secret: require header x-ingest-secret === env ENODE_INGEST_SECRET. For each entry: upsert TextContent by key (set sourceText, context, namespace, clear deprecatedSince) and upsert the en Translation (value = text, status "published"). Then mark every TextContent whose key is NOT in the payload and not already deprecated as deprecatedSince = <today's ISO date> — never delete. Validate the body with zod. Return a summary { created, updated, deprecated }.

Screens (App Router, server components + minimal client islands):

  1. / — list of TextContent with filters (namespace, search) and a per-language "missing/draft/published" badge; rows show context.
  2. /keys/[id] — editor: source text + a row per enabled language with the value, status toggle (draft/published), and a "Translate" button (DeepL).
  3. /languages — manage Language (add/enable; "add a language" = create + translate + publish, then it appears in the apps without a rebuild).
  4. /deprecated — list deprecatedSince keys with a delete action (guarded by a confirm) for cleanup once no supported app version uses them.
  5. Import/Export — CSV/JSON import (one-off migration from the old Google Sheet) and export.

Machine translation (src/lib/mt.ts). A translate(text, targetLocale) using the DeepL API (env DEEPL_API_KEY, free endpoint https://api-free.deepl.com/v2/translate). The editor's "Translate" button and a "Pre-translate missing" bulk action create draft translations for review.

Publish (POST /api/publish + a button). For each Translation with status "published" that is newer than its last sync, call upsertTextContent against the backend with that locale and the TextContent.key; store the returned backend id in TextContent.backendId for subsequent updates. Publishing is the only thing that writes to the backend; the apps then receive it OTA via GET /text_contents.

Env (.env): ENODE_API_BASE, ENODE_API_KEY, ENODE_DEVICE_ID, ENODE_INGEST_SECRET, DEEPL_API_KEY, DATABASE_URL.

Use design tokens / a clean minimal UI; this is an internal tool, so favour clarity over polish. Add a short README.md documenting the env vars, the /api/ingest contract, and the publish flow. Do not commit secrets.

Step 3 — connect it for automatic insertion

The monorepo already produces the source strings; you only need to point the push at the admin tool and let the admin tool publish to the backend.

  1. Code → admin tool (automatic). npm run i18n:extract runs in the pre-push hook + CI; npm run i18n:push runs in CI on merge to main (.github/workflows/i18n-check.yml). The push does POST {ENODE_I18N_BASE_URL:-https://translations.enode.ai}/api/ingest with content-type: application/json and authorization: Bearer <secret>, body { sourceLocale: "en", entries: [{ key, text, context, usages, namespace, locations }] }. usages is the auto-derived list of translator-facing usage descriptions ("Button label (call to action)", "Placeholder text shown inside an empty input field", …) the extractor classifies from the AST — shown to translators in the admin tool, not part of the key, no code details. Set the repo secrets:

    • ENODE_API_TOKEN = <secret> — sent as the bearer; the Worker validates it against its own ENODE_INGEST_SECRET (the two must be identical). It's a shared secret, not a backend token. Generate a strong one with openssl rand -base64 32.
    • CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET — a Cloudflare Access service token allowed on the translations.enode.ai Access application; Access checks it at the edge before the Worker sees the request. (The host defaults to https://translations.enode.ai; override via ENODE_I18N_BASE_URL only for testing another instance.)

    The ingest treats the payload as a full snapshot and upserts by key (idempotent): any key not in the payload is marked deprecated_since, never deleted. It returns 200 { created, updated, deprecated }. The push refuses to send an empty extraction (which would deprecate everything) unless --allow-empty is passed. Result: every merged t("…") string lands in the admin tool as "needs translation", with its context, usage descriptions, and namespace, no manual step.

  2. Admin tool → backend (on publish). The admin logs in (email+OTP → bearer token), translates (DeepL + review), and clicks Publish. The tool writes each published value into the backend text_contents using the same request style as the appsapi-key + the admin's bearer token + the device headers + Accept-Language: <locale> + body { id, key, value }. Model the exact endpoint on the existing Enode write requests (the read is GET /text_contents; create/update mirror the other POST/PUT /…/{id} endpoints).

  3. Backend → apps (automatic, OTA). The apps fetch GET /text_contents on launch and on locale switch and resolve UI strings by key. Published or fixed translations appear on the next launch — no app-store release.

The one backend dependency

For the apps to resolve UI strings client-side by content-hash, the backend text_contents must carry the key field: accept it on write and return it on GET /text_contents (null for entity rows). The read/write endpoints already exist; this is the only additive, backward-compatible change needed. Until it ships you have two options:

  • Recommended: add the key column (read + write) — small, additive.
  • No-backend-change fallback: have the admin tool also expose GET /api/catalog?locale=xx returning { key: value }, and point a thin alternate loader in the apps at it. The engine's byKey resolution is identical; only the fetch URL differs. Switch to backend delivery later with no app change.

Entity text (exercise/metric names) needs none of this — it already flows through GET /text_contents by UUID and only needed the Accept-Language header, which the client now sends.