From ef94646d607fff9188dd8c101d3a4394f6a4dec8 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 6 Sep 2026 17:55:11 +0200 Subject: [PATCH] fix(i18n): persist CMS translations and validate message catalogs --- .gitea/workflows/ci.yaml | 3 + CMS_TRANSLATIONS.md | 30 ++ package.json | 10 +- pnpm-lock.yaml | 6 + scripts/audit-cms-translations.mjs | 189 ++++++++ src/actions/translations.ts | 63 +-- src/app/admin/error.tsx | 2 +- src/app/admin/translations/cms/page.tsx | 62 +-- .../translations/translations-client.tsx | 440 +++++++++++------- src/i18n/catalog-messages.test.ts | 39 ++ src/i18n/locales.ts | 32 ++ src/i18n/request.ts | 51 +- src/lib/cms-message-validation.ts | 36 ++ src/lib/cms-translations.test.ts | 108 +++++ src/lib/cms-translations.ts | 133 ++++++ src/messages/bg.json | 10 +- src/messages/cs.json | 10 +- src/messages/da.json | 10 +- src/messages/de.json | 10 +- src/messages/el.json | 10 +- src/messages/en.json | 300 ++++++++++-- src/messages/es.json | 10 +- src/messages/fr.json | 10 +- src/messages/hr.json | 10 +- src/messages/hu.json | 10 +- src/messages/it.json | 345 ++++++++++++-- src/messages/nl.json | 45 +- src/messages/no.json | 10 +- src/messages/pl.json | 10 +- src/messages/pt.json | 10 +- src/messages/ro.json | 10 +- src/messages/ru.json | 10 +- src/messages/sk.json | 10 +- src/messages/sr.json | 10 +- src/messages/sv.json | 10 +- src/messages/tr.json | 10 +- src/messages/uk.json | 10 +- 37 files changed, 1621 insertions(+), 463 deletions(-) create mode 100644 CMS_TRANSLATIONS.md create mode 100644 scripts/audit-cms-translations.mjs create mode 100644 src/i18n/catalog-messages.test.ts create mode 100644 src/i18n/locales.ts create mode 100644 src/lib/cms-message-validation.ts create mode 100644 src/lib/cms-translations.test.ts create mode 100644 src/lib/cms-translations.ts diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index c2c3c92d..7c7cc229 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -37,6 +37,9 @@ jobs: - name: Lint run: pnpm biome:lint + - name: CMS translation contracts + run: pnpm i18n:check + - name: Typecheck run: pnpm typecheck diff --git a/CMS_TRANSLATIONS.md b/CMS_TRANSLATIONS.md new file mode 100644 index 00000000..e54fb8c5 --- /dev/null +++ b/CMS_TRANSLATIONS.md @@ -0,0 +1,30 @@ +# CMS translation audit and editor + +The CMS editor at `/admin/translations/cms` now uses the same bundled catalogs as the request-time translator. Runtime changes are stored separately in `storage/cms-translations/.json`, inside the existing persistent `/app/storage` mount. No source-file write, environment-variable change or rebuild is required to apply an edit. + +Only overrides are saved. Unchanged messages continue to receive updates from Git. Saves use a file lock, an atomic replacement and a revision check; a stale editor cannot overwrite another operator's changes. ICU syntax, argument names, rich-text tags and allowed keys are checked server-side. Invalid or obsolete overrides are excluded when reading a new release. Storage read errors are reported to the CMS error monitor and public pages fall back to bundled text. + +The page starts in the operator's language. It shows all English reference keys, including missing translations, and supports search by key, translated text or English source. Filters separate missing, identical and modified text. Drafts survive language switches and failed saves. Users without `SETTINGS_EDIT` can review and export but cannot save. + +## Audit outcome, 6 September 2026 + +- Scanned 25 JSON catalogs; 22 languages are selectable. The small Arabic, Finnish and Japanese catalogs are legacy files and remain outside the supported locale list. +- Repaired 66 malformed ICU messages across the 22 active catalogs, including HTML fragments and unescaped JSON examples. +- Added 199 missing English reference keys used by page components, with Italian translations, and fixed the incorrect navigation namespace in the admin error page. +- Completed the 31 previously missing Italian reference keys. +- Repaired missing `count` and `preset` variables in other locales. +- Final checks: zero malformed messages, zero argument/tag mismatches and zero missing references among the statically resolved translation calls. +- English contains 3,423 reference keys. Italian covers all of them; 629 values match English. Dutch is missing 524 reference keys and has 618 identical values. Matching English can be intentional for names and technical labels; this is not proof of translation quality. +- Found 1,577 literal JSX text candidates outside translation calls. These include labels, technical strings and names; they are an editorial inventory, not 1,577 confirmed bugs. The largest concentrations are the catalog item table (118), Studio main component (79), import audit (53), sound management (50) and permission editor (42). + +## Repeatable checks + +- `pnpm i18n:check`: fails on malformed messages, incompatible variables/tags, empty messages, source parsing errors or missing statically referenced keys. Runs in Gitea CI. +- `pnpm i18n:audit`: prints coverage and findings. +- `node scripts/audit-cms-translations.mjs --json`: full machine-readable inventory, including file and line references for literal JSX candidates. + +Static analysis resolves literal translator namespaces and literal message keys. Dynamic key construction, prose embedded in arbitrary JavaScript strings, and the linguistic accuracy of all 22 translations still require targeted review. English fallback remains explicit; copying English into other catalogs would hide untranslated entries and is intentionally avoided. + +## Validation + +Automated tests exercise message syntax, actual translator output, persistent overrides, invalid-message rejection, revision conflicts and reset behavior. A browser fixture mounts the real editor and verifies missing-key editing, validation, failed-save preservation, language switching, successful saves, read-only access and mobile layout. Server actions are simulated in that fixture; authenticated production editing requires a staff session. diff --git a/package.json b/package.json index 43f49e7a..da5aac77 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,9 @@ "prepare": "node scripts/prepare-hooks.mjs", "assets:editor": "node scripts/copy-editor-assets.mjs", "deps:audit": "pnpm audit --audit-level=high", - "analyze": "next experimental-analyze" + "analyze": "next experimental-analyze", + "i18n:check": "node scripts/audit-cms-translations.mjs --check", + "i18n:audit": "node scripts/audit-cms-translations.mjs" }, "lint-staged": { "*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched", @@ -41,6 +43,7 @@ "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", + "@formatjs/icu-messageformat-parser": "3.5.17", "@hookform/resolvers": "5.9.1", "@tanstack/react-virtual": "3.14.10", "class-variance-authority": "0.7.1", @@ -72,10 +75,11 @@ "sharp": "^0.35.4", "sonner": "2.0.8", "tailwind-merge": "3.6.0", - "zod": "4.5.4", - "tinymce": "8.9.0" + "tinymce": "8.9.0", + "zod": "4.5.4" }, "devDependencies": { + "@babel/parser": "7.29.8", "@biomejs/biome": "2.5.12", "@playwright/test": "^1.62.1", "@tailwindcss/forms": "0.5.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a54e2485..bed4d714 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: '@dnd-kit/utilities': specifier: 3.2.2 version: 3.2.2(react@19.2.8) + '@formatjs/icu-messageformat-parser': + specifier: 3.5.17 + version: 3.5.17 '@hookform/resolvers': specifier: 5.9.1 version: 5.9.1(@standard-schema/spec@1.1.0)(react-hook-form@7.87.0(react@19.2.8))(zod@4.5.4) @@ -125,6 +128,9 @@ importers: specifier: 4.5.4 version: 4.5.4 devDependencies: + '@babel/parser': + specifier: 7.29.8 + version: 7.29.8 '@biomejs/biome': specifier: 2.5.12 version: 2.5.12 diff --git a/scripts/audit-cms-translations.mjs b/scripts/audit-cms-translations.mjs new file mode 100644 index 00000000..7d8edb53 --- /dev/null +++ b/scripts/audit-cms-translations.mjs @@ -0,0 +1,189 @@ +import fs from "node:fs"; +import path from "node:path"; +import * as babel from "@babel/parser"; +import { parse } from "@formatjs/icu-messageformat-parser"; + +const flat = (obj, p = "", r = {}) => { + for (const [k, v] of Object.entries(obj)) { + const key = p ? `${p}.${k}` : k; + if (v && typeof v === "object") flat(v, key, r); + else r[key] = v; + } + return r; +}; +const messages = Object.fromEntries( + fs + .readdirSync("src/messages") + .filter((n) => n.endsWith(".json")) + .map((n) => [ + n.slice(0, -5), + flat(JSON.parse(fs.readFileSync(`src/messages/${n}`, "utf8"))), + ]), +); +const base = messages.en, + invalid = [], + mismatches = []; +function vars(ast, r = new Set()) { + for (const n of ast) { + if ([1, 2, 3, 4, 5, 6, 8].includes(n.type)) r.add(n.value); + if (n.options) for (const o of Object.values(n.options)) vars(o.value, r); + if (n.children) vars(n.children, r); + } + return [...r].sort().join(","); +} +const signatures = {}; +for (const [k, v] of Object.entries(base)) { + try { + signatures[k] = vars(parse(v)); + } catch {} +} +const locales = Object.entries(messages).map(([locale, m]) => { + for (const [k, v] of Object.entries(m)) { + try { + const sig = vars(parse(v)); + if (k in signatures && sig !== signatures[k]) + mismatches.push({ + locale, + key: k, + expected: signatures[k], + actual: sig, + value: v, + }); + } catch (e) { + invalid.push({ locale, key: k, value: v, error: e.message }); + } + } + return { + locale, + total: Object.keys(m).length, + missing: Object.keys(base).filter((k) => !(k in m)).length, + empty: Object.values(m).filter((v) => !v?.trim()).length, + sameAsEnglish: Object.keys(base).filter((k) => m[k] === base[k]).length, + }; +}); +const missingCalls = [], + hardcoded = [], + unparsedFiles = []; +function walk(n, fn) { + if (!n || typeof n !== "object") return; + fn(n); + for (const [k, v] of Object.entries(n)) { + if (k === "loc" || k === "extra") continue; + if (Array.isArray(v)) + v.forEach((x) => { + walk(x, fn); + }); + else if (v && typeof v === "object") walk(v, fn); + } +} +for (const f of fs + .readdirSync("src", { recursive: true }) + .filter((f) => /\.(tsx|ts)$/.test(f) && !f.includes(".test."))) { + let ast; + try { + ast = babel.parse(fs.readFileSync(path.join("src", f), "utf8"), { + sourceType: "module", + plugins: ["typescript", "jsx"], + }); + } catch (error) { + unparsedFiles.push({ file: f, error: error.message }); + continue; + } + const bindings = {}; + walk(ast, (n) => { + if (n.type === "VariableDeclarator" && n.id.type === "Identifier") { + let c = n.init; + if (c?.type === "AwaitExpression") c = c.argument; + if ( + c?.type === "CallExpression" && + ["useTranslations", "getTranslations"].includes(c.callee.name) + ) { + const arg = c.arguments[0]; + const ns = + arg?.type === "StringLiteral" + ? arg.value + : arg?.type === "ObjectExpression" + ? arg.properties.find((p) => p.key?.name === "namespace")?.value + ?.value + : arg + ? null + : ""; + if (ns !== null) { + bindings[n.id.name] ??= new Set(); + bindings[n.id.name].add(ns); + } + } + } + }); + walk(ast, (n) => { + if (n.type === "CallExpression") { + const name = n.callee.name ?? n.callee.object?.name; + const ns = bindings[name]; + const k = n.arguments[0]; + if (ns?.size === 1 && k?.type === "StringLiteral") { + const full = [...ns][0] ? `${[...ns][0]}.${k.value}` : k.value; + if ( + !(full in base) && + !Object.keys(base).some((b) => b.startsWith(`${full}.`)) + ) + missingCalls.push({ file: f, line: n.loc.start.line, key: full }); + } + } + if (n.type === "JSXText" && /[A-Za-z]{3}/.test(n.value.trim())) + hardcoded.push({ + file: f, + line: n.loc.start.line, + text: n.value.trim().replace(/\s+/g, " "), + }); + }); +} +const result = { + locales, + invalid, + mismatches, + missingCalls, + hardcoded, + unparsedFiles, +}; +if (process.argv.includes("--json")) + console.log(JSON.stringify(result, null, 2)); +else { + console.table(locales); + console.log( + "Invalid ICU:", + invalid.length, + "Variable mismatches:", + mismatches.length, + "Missing static message references:", + missingCalls.length, + ); + console.log( + "Literal JSX text candidates requiring editorial review:", + hardcoded.length, + ); + console.log( + "Coverage checks static translator namespaces and literal keys; dynamic keys and semantic translation quality require review.", + ); + if ( + invalid.length || + mismatches.length || + missingCalls.length || + unparsedFiles.length + ) + console.log( + JSON.stringify( + { invalid, mismatches, missingCalls, unparsedFiles }, + null, + 2, + ), + ); +} +if ( + process.argv.includes("--check") && + (invalid.length || + mismatches.length || + missingCalls.length || + unparsedFiles.length || + locales.some((locale) => locale.empty > 0)) +) + process.exitCode = 1; diff --git a/src/actions/translations.ts b/src/actions/translations.ts index 3163ec76..6cf6dfd5 100644 --- a/src/actions/translations.ts +++ b/src/actions/translations.ts @@ -3,60 +3,47 @@ import fs from "node:fs/promises"; import path from "node:path"; import * as JSONC from "jsonc-parser"; +import { revalidatePath } from "next/cache"; +import { getTranslations } from "next-intl/server"; import { z } from "zod"; +import { SUPPORTED_LOCALES } from "@/i18n/locales"; import { CLIENT_TRANSLATION_FILES, getClientTranslationFile, } from "@/lib/client-translation-files"; +import { + CmsTranslationError, + saveCmsTranslation, +} from "@/lib/cms-translations"; import { patchJson5 } from "@/lib/json5-patch"; import { PERMS } from "@/lib/permissions"; import { adminAction } from "@/lib/safe-action"; import { ActionError, actionOk } from "@/lib/safe-action-shared"; const saveTranslationsSchema = z.object({ - locale: z.enum([ - "en", - "it", - "nl", - "de", - "fr", - "es", - "pt", - "pl", - "sv", - "tr", - "ro", - "hu", - "cs", - "sk", - "da", - "no", - "el", - "bg", - "hr", - "sr", - "uk", - "ru", - ]), - data: z.record(z.string(), z.unknown()), + locale: z.enum(SUPPORTED_LOCALES), + revision: z.string().regex(/^[a-f0-9]{64}$/), + changes: z + .record(z.string().max(300), z.string().max(20000).nullable()) + .refine((value) => Object.keys(value).length <= 5000), }); export const saveTranslations = adminAction( { permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema }, async (ctx) => { - const filePath = path.join( - process.cwd(), - "src", - "messages", - `${ctx.data.locale}.json`, - ); - await fs.writeFile( - filePath, - JSON.stringify(ctx.data.data, null, 2), - "utf-8", - ); - - return actionOk(); + try { + const snapshot = await saveCmsTranslation( + ctx.data.locale, + ctx.data.revision, + ctx.data.changes, + ); + revalidatePath("/", "layout"); + return actionOk({ snapshot }); + } catch (error) { + if (!(error instanceof CmsTranslationError)) throw error; + const t = await getTranslations("pages.admin.translations.cms"); + return { ok: false, error: t(error.code, { key: error.key }) }; + } }, ); diff --git a/src/app/admin/error.tsx b/src/app/admin/error.tsx index 6f9158f8..cb071f1e 100644 --- a/src/app/admin/error.tsx +++ b/src/app/admin/error.tsx @@ -18,7 +18,7 @@ export default function AdminErrorPage({ reset: () => void; }) { const t = useTranslations("pages.error"); - const tNav = useTranslations("admin.nav"); + const tNav = useTranslations("pages.admin.nav"); const [reference, setReference] = useState(error.digest); useEffect(() => { diff --git a/src/app/admin/translations/cms/page.tsx b/src/app/admin/translations/cms/page.tsx index fce76a0e..fb28ef0e 100644 --- a/src/app/admin/translations/cms/page.tsx +++ b/src/app/admin/translations/cms/page.tsx @@ -1,48 +1,24 @@ -import fs from "node:fs/promises"; -import path from "node:path"; +import { getLocale } from "next-intl/server"; +import { SUPPORTED_LOCALES } from "@/i18n/locales"; +import { readCmsTranslation } from "@/lib/cms-translations"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { TranslationsClient } from "../translations-client"; -const LOCALES = [ - "en", - "it", - "nl", - "de", - "fr", - "es", - "pt", - "pl", - "sv", - "tr", - "ro", - "hu", - "cs", - "sk", - "da", - "no", - "el", - "bg", - "hr", - "sr", - "uk", - "ru", -] as const; - export default async function CmsTranslationsPage() { - const messagesDir = path.join(process.cwd(), "src", "messages"); - - const contents: Record = {}; - await Promise.all( - LOCALES.map(async (locale) => { - try { - contents[locale] = await fs.readFile( - path.join(messagesDir, `${locale}.json`), - "utf-8", - ); - } catch { - // Skip missing locale files instead of failing the whole page. - } - }), + const [{ session, permissions }, locale, entries] = await Promise.all([ + getAdminContext(), + getLocale(), + Promise.all( + SUPPORTED_LOCALES.map( + async (locale) => [locale, await readCmsTranslation(locale)] as const, + ), + ), + ]); + return ( + ); - - return ; } diff --git a/src/app/admin/translations/translations-client.tsx b/src/app/admin/translations/translations-client.tsx index 39664354..227294c6 100644 --- a/src/app/admin/translations/translations-client.tsx +++ b/src/app/admin/translations/translations-client.tsx @@ -1,132 +1,127 @@ "use client"; import { Download, Save } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; import { saveTranslations } from "@/actions/translations"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useServerAction } from "@/hooks/use-server-action"; -import { flattenObject, unflattenObject } from "@/lib/translations-utils"; - -type Locale = - | "en" - | "it" - | "nl" - | "de" - | "fr" - | "es" - | "pt" - | "pl" - | "sv" - | "tr" - | "ro" - | "hu" - | "cs" - | "sk" - | "da" - | "no" - | "el" - | "bg" - | "hr" - | "sr" - | "uk" - | "ru"; - -const LOCALES: { code: Locale; label: string }[] = [ - { code: "en", label: "EN" }, - { code: "it", label: "IT" }, - { code: "nl", label: "NL" }, - { code: "de", label: "DE" }, - { code: "fr", label: "FR" }, - { code: "es", label: "ES" }, - { code: "pt", label: "PT" }, - { code: "pl", label: "PL" }, - { code: "sv", label: "SV" }, - { code: "tr", label: "TR" }, - { code: "ro", label: "RO" }, - { code: "hu", label: "HU" }, - { code: "cs", label: "CS" }, - { code: "sk", label: "SK" }, - { code: "da", label: "DA" }, - { code: "no", label: "NO" }, - { code: "el", label: "EL" }, - { code: "bg", label: "BG" }, - { code: "hr", label: "HR" }, - { code: "sr", label: "SR" }, - { code: "uk", label: "UK" }, - { code: "ru", label: "RU" }, -]; +import { useUnsavedChanges } from "@/hooks/use-unsaved-changes"; +import { + type AppLocale, + isSupportedLocale, + SUPPORTED_LOCALES, +} from "@/i18n/locales"; +import { validateCmsMessage } from "@/lib/cms-message-validation"; +import type { CmsTranslationSnapshot } from "@/lib/cms-translations"; +import { unflattenObject } from "@/lib/translations-utils"; interface Props { - localeContents: Record; + snapshots: Record; + initialLocale: string; + canEdit: boolean; } - -export function TranslationsClient({ localeContents }: Props) { - const [data, setData] = useState>>( - () => { - const initial: Record> = {}; - for (const [locale, content] of Object.entries(localeContents)) { - initial[locale] = flattenObject(JSON.parse(content)); - } - return initial; - }, +const PER_PAGE = 30; +export function TranslationsClient({ + snapshots, + initialLocale, + canEdit, +}: Props) { + const t = useTranslations("pages.admin.translations.cms"); + const router = useRouter(); + const [saved, setSaved] = useState(snapshots); + const [drafts, setDrafts] = useState>>( + {}, + ); + const [activeLang, setActiveLang] = useState( + isSupportedLocale(initialLocale) ? initialLocale : "en", ); const [search, setSearch] = useState(""); const [group, setGroup] = useState(""); - const [activeLang, setActiveLang] = useState("en"); - const { run, isPending } = useServerAction(); - const PER_PAGE = 30; + const [status, setStatus] = useState("all"); const [page, setPage] = useState(1); - - const activeData = data[activeLang]; - - const groups = useMemo(() => { - const g = new Set(); - Object.keys(data.en ?? {}).forEach((k) => { - const parts = k.split("."); - if (parts.length > 1) g.add(parts[0]); - }); - return Array.from(g).sort(); - }, [data]); - - const filteredKeys = useMemo(() => { - if (!activeData) return []; - return Object.keys(activeData).filter((key) => { - const matchSearch = - !search || - key.toLowerCase().includes(search.toLowerCase()) || - activeData[key].toLowerCase().includes(search.toLowerCase()); - const matchGroup = !group || key.startsWith(`${group}.`); - return matchSearch && matchGroup; + const { run, isPending } = useServerAction(); + const source = saved.en.messages; + const activeData = saved[activeLang].messages; + const draft = drafts[activeLang] ?? {}; + const keys = useMemo(() => Object.keys(source).sort(), [source]); + const groups = useMemo( + () => [...new Set(keys.map((key) => key.split(".")[0]))], + [keys], + ); + const dirty = Object.values(drafts).some( + (values) => Object.keys(values).length > 0, + ); + useUnsavedChanges(dirty, t("leaveWarning")); + const valueFor = (key: string) => draft[key] ?? activeData[key] ?? ""; + const missing = keys.filter((key) => !valueFor(key).trim()).length; + const identical = + activeLang === "en" + ? 0 + : keys.filter((key) => valueFor(key) === source[key]).length; + const errors: Record = {}; + for (const [key, value] of Object.entries(draft)) { + const issue = validateCmsMessage(value, source[key]); + if (issue) errors[key] = t(issue, { key }); + } + const filteredKeys = keys.filter((key) => { + const value = valueFor(key); + const matchesSearch = [key, value, source[key]].some((text) => + text.toLocaleLowerCase().includes(search.toLocaleLowerCase()), + ); + const matchesStatus = + status === "all" || + (status === "missing" && !activeData[key]?.trim()) || + (status === "identical" && + activeLang !== "en" && + activeData[key] === source[key]) || + (status === "modified" && Object.hasOwn(draft, key)); + return ( + matchesSearch && matchesStatus && (!group || key.startsWith(`${group}.`)) + ); + }); + const totalPages = Math.max(1, Math.ceil(filteredKeys.length / PER_PAGE)); + const currentPage = Math.min(page, totalPages); + const pageKeys = filteredKeys.slice( + (currentPage - 1) * PER_PAGE, + currentPage * PER_PAGE, + ); + function updateValue(key: string, value: string) { + setDrafts((current) => { + const next = { ...current[activeLang] }; + if (value === (activeData[key] ?? "")) delete next[key]; + else next[key] = value; + return { ...current, [activeLang]: next }; }); - }, [activeData, search, group]); - - const totalPages = Math.ceil(filteredKeys.length / PER_PAGE); - const pageKeys = filteredKeys.slice((page - 1) * PER_PAGE, page * PER_PAGE); - - function updateValue(key: string, value: string) { - setData({ ...data, [activeLang]: { ...activeData, [key]: value } }); } - function handleSave() { + if (!canEdit || !Object.keys(draft).length || Object.keys(errors).length) + return; + const locale = activeLang; run( () => saveTranslations({ - locale: activeLang, - data: unflattenObject(activeData), + locale, + revision: saved[locale].revision, + changes: draft, }), { - successMessage: "Translations saved!", + successMessage: t("saved"), + onSuccess: (result) => { + const snapshot = result.snapshot as CmsTranslationSnapshot; + setSaved((current) => ({ ...current, [locale]: snapshot })); + setDrafts((current) => ({ ...current, [locale]: {} })); + router.refresh(); + }, }, ); } - function handleExport() { const blob = new Blob( - [JSON.stringify(unflattenObject(activeData), null, 2)], - { - type: "application/json", - }, + [JSON.stringify(unflattenObject({ ...activeData, ...draft }), null, 2)], + { type: "application/json" }, ); const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -135,106 +130,213 @@ export function TranslationsClient({ localeContents }: Props) { a.click(); URL.revokeObjectURL(url); } - return ( -
-
-
+
+
+
+

{t("title")}

+

+ {t("description")} +

+
+
- + {canEdit && ( + + )}
- -
-
- {LOCALES.map(({ code, label }) => ( + {!canEdit && ( +

{t("readOnly")}

+ )} +
+
+ {SUPPORTED_LOCALES.map((code) => ( ))} -
- { - setSearch(e.target.value); - setPage(1); - }} - className="max-w-xs" - /> - - - {filteredKeys.length} keys - -
- -
- {pageKeys.map((key) => ( -
- - {key} - + +

+ {t("coverage", { total: keys.length, missing, identical })} +

+
+ + + +
+

{t("fallbackHint")}

+ {Object.keys(draft).length > 0 && ( +
+ + {t("unsaved", { count: Object.keys(draft).length })} + + + {Object.keys(errors).length > 0 && ( + + {t("fixErrors", { count: Object.keys(errors).length })} + + )}
- ))} -
- - {totalPages > 1 && ( -
+ )} +
+ {pageKeys.length === 0 && ( +

+ {t("noResults")} +

+ )} + {pageKeys.map((key) => ( +
+ + {activeLang !== "en" && ( +

+ {t("source")}: + {source[key]} +

+ )} +