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), withprisma+@prisma/client+zodinstalled 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 aTextContentidentified by a stable content-hashkey.
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 backendtext_contents.idonce published),deprecatedSince(nullable string),createdAt,updatedAt.Translation:id,textContentId(FK),locale,value,status("draft" | "published"),updatedAt. Unique on (textContentId,locale). Theentranslation always equalssourceText.Run
npx prisma migrate dev --name initafter writing the schema, and add asrc/lib/db.tsexporting a singletonPrismaClient.Backend client (
src/lib/enode.ts). Model it on the Enode web client. Every request sends these headers:api-key(envENODE_API_KEY),Authorization: Bearer <token>(the admin user's token, see auth below),device-type: Portal,device-name: TranslationsAdmin,device-id(envENODE_DEVICE_IDor a constant),x-user-agent: EnodeTranslationsAdmin/1.0 (web),accept: application/json, andaccept-language: <locale>— the backend selects the locale from this header, there is no?localequery. Base URL from envENODE_API_BASE(defaulthttps://developapi.enode.ai/api). Implement:
login(email, otp):POST /users/loginwith HTTP Basic auth headerAuthorization: Basic base64("email:otp")andapi-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. Sendaccept-language: <locale>and body{ id, key, value }. Ifidis absent, create (the backend returns the new row incl. itsid); if present, update. (Confirm the exact path/shape against the backend —POST /text_contentsto create,PUT /text_contents/{id}to update, mirroring the other write endpoints; the create DTO is{ id, value }plus the newkey.)Auth (admin session). A minimal email+OTP login page that calls
requestOtpthenlogin, 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 headerx-ingest-secret === env ENODE_INGEST_SECRET. For each entry: upsertTextContentbykey(setsourceText,context,namespace, cleardeprecatedSince) and upsert theenTranslation(value = text, status "published"). Then mark everyTextContentwhosekeyis NOT in the payload and not already deprecated asdeprecatedSince = <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):
/— list ofTextContentwith filters (namespace, search) and a per-language "missing/draft/published" badge; rows showcontext./keys/[id]— editor: source text + a row per enabled language with the value, status toggle (draft/published), and a "Translate" button (DeepL)./languages— manageLanguage(add/enable; "add a language" = create + translate + publish, then it appears in the apps without a rebuild)./deprecated— listdeprecatedSincekeys with a delete action (guarded by a confirm) for cleanup once no supported app version uses them.- Import/Export — CSV/JSON import (one-off migration from the old Google Sheet) and export.
Machine translation (
src/lib/mt.ts). Atranslate(text, targetLocale)using the DeepL API (envDEEPL_API_KEY, free endpointhttps://api-free.deepl.com/v2/translate). The editor's "Translate" button and a "Pre-translate missing" bulk action createdrafttranslations for review.Publish (
POST /api/publish+ a button). For eachTranslationwith status "published" that is newer than its last sync, callupsertTextContentagainst the backend with that locale and theTextContent.key; store the returned backendidinTextContent.backendIdfor subsequent updates. Publishing is the only thing that writes to the backend; the apps then receive it OTA viaGET /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.mddocumenting the env vars, the/api/ingestcontract, 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.
-
Code → admin tool (automatic).
npm run i18n:extractruns in the pre-push hook + CI;npm run i18n:pushruns in CI on merge tomain(.github/workflows/i18n-check.yml). The push doesPOST {ENODE_I18N_BASE_URL:-https://translations.enode.ai}/api/ingestwithcontent-type: application/jsonandauthorization: Bearer <secret>, body{ sourceLocale: "en", entries: [{ key, text, context, usages, namespace, locations }] }.usagesis 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 ownENODE_INGEST_SECRET(the two must be identical). It's a shared secret, not a backend token. Generate a strong one withopenssl rand -base64 32.CF_ACCESS_CLIENT_ID/CF_ACCESS_CLIENT_SECRET— a Cloudflare Access service token allowed on thetranslations.enode.aiAccess application; Access checks it at the edge before the Worker sees the request. (The host defaults tohttps://translations.enode.ai; override viaENODE_I18N_BASE_URLonly 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 markeddeprecated_since, never deleted. It returns200 { created, updated, deprecated }. The push refuses to send an empty extraction (which would deprecate everything) unless--allow-emptyis passed. Result: every mergedt("…")string lands in the admin tool as "needs translation", with its context, usage descriptions, and namespace, no manual step. -
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_contentsusing the same request style as the apps —api-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 isGET /text_contents; create/update mirror the otherPOST/PUT /…/{id}endpoints). -
Backend → apps (automatic, OTA). The apps fetch
GET /text_contentson launch and on locale switch and resolve UI strings bykey. 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
keycolumn (read + write) — small, additive. - No-backend-change fallback: have the admin tool also expose
GET /api/catalog?locale=xxreturning{ key: value }, and point a thin alternate loader in the apps at it. The engine'sbyKeyresolution 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.