fix(i18n): persist CMS translations and validate message catalogs
This commit is contained in:
1 parent
96234075f8
commit
ef94646d60
37 files changed
+1621
-463
No files matched your search
@@ -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(() => {
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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 (
|
||||
<TranslationsClient
|
||||
snapshots={Object.fromEntries(entries)}
|
||||
initialLocale={locale}
|
||||
canEdit={canAccess(permissions, PERMS.SETTINGS_EDIT, session.user.rank)}
|
||||
/>
|
||||
);
|
||||
|
||||
return <TranslationsClient localeContents={contents} />;
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
snapshots: Record<string, CmsTranslationSnapshot>;
|
||||
initialLocale: string;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function TranslationsClient({ localeContents }: Props) {
|
||||
const [data, setData] = useState<Record<string, Record<string, string>>>(
|
||||
() => {
|
||||
const initial: Record<string, Record<string, string>> = {};
|
||||
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<Record<string, Record<string, string>>>(
|
||||
{},
|
||||
);
|
||||
const [activeLang, setActiveLang] = useState<AppLocale>(
|
||||
isSupportedLocale(initialLocale) ? initialLocale : "en",
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [group, setGroup] = useState("");
|
||||
const [activeLang, setActiveLang] = useState<Locale>("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<string>();
|
||||
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<string, string> = {};
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<div className="flex gap-2">
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
<Download className="w-4 h-4 mr-1" /> Export
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={isPending}>
|
||||
<Save className="w-4 h-4 mr-1" /> {isPending ? "Saving..." : "Save"}
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
{t("export")}
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
isPending ||
|
||||
!Object.keys(draft).length ||
|
||||
!!Object.keys(errors).length
|
||||
}
|
||||
>
|
||||
<Save className="mr-1 h-4 w-4" />
|
||||
{isPending
|
||||
? t("saving")
|
||||
: t("saveLocale", { locale: activeLang.toUpperCase() })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-1 overflow-x-auto pb-1 max-w-full">
|
||||
{LOCALES.map(({ code, label }) => (
|
||||
{!canEdit && (
|
||||
<p className="text-sm text-muted-foreground">{t("readOnly")}</p>
|
||||
)}
|
||||
<fieldset disabled={isPending} className="min-w-0 space-y-4">
|
||||
<fieldset
|
||||
className="flex min-w-0 flex-wrap gap-1"
|
||||
aria-label={t("language")}
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((code) => (
|
||||
<Button
|
||||
key={code}
|
||||
variant={activeLang === code ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
variant={activeLang === code ? "default" : "outline"}
|
||||
aria-pressed={activeLang === code}
|
||||
onClick={() => {
|
||||
setActiveLang(code);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{code.toUpperCase()}
|
||||
{Object.keys(drafts[code] ?? {}).length ? " *" : ""}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Filter by key or value..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<select
|
||||
className="rounded-md border bg-background px-3 py-1 text-sm"
|
||||
value={group}
|
||||
onChange={(e) => {
|
||||
setGroup(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">All groups</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-sm text-muted-foreground self-center">
|
||||
{filteredKeys.length} keys
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card divide-y">
|
||||
{pageKeys.map((key) => (
|
||||
<div key={key} className="flex items-center gap-3 px-4 py-2">
|
||||
<span className="text-xs font-mono text-muted-foreground w-64 truncate flex-shrink-0">
|
||||
{key}
|
||||
</span>
|
||||
</fieldset>
|
||||
<p className="text-sm" role="status">
|
||||
{t("coverage", { total: keys.length, missing, identical })}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label
|
||||
htmlFor="cms-translation-search"
|
||||
className="grid w-full min-w-0 gap-1 text-sm md:w-auto md:flex-1"
|
||||
>
|
||||
{t("search")}
|
||||
<Input
|
||||
value={activeData[key]}
|
||||
onChange={(e) => updateValue(key, e.target.value)}
|
||||
className="flex-1 h-8 text-sm"
|
||||
id="cms-translation-search"
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
{t("group")}
|
||||
<select
|
||||
value={group}
|
||||
onChange={(e) => {
|
||||
setGroup(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="rounded-md border bg-background px-3 py-2"
|
||||
>
|
||||
<option value="">{t("allGroups")}</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
{t("status")}
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="rounded-md border bg-background px-3 py-2"
|
||||
>
|
||||
<option value="all">{t("all")}</option>
|
||||
<option value="missing">{t("missing")}</option>
|
||||
{activeLang !== "en" && (
|
||||
<option value="identical">{t("identical")}</option>
|
||||
)}
|
||||
<option value="modified">{t("modified")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("fallbackHint")}</p>
|
||||
{Object.keys(draft).length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm">
|
||||
{t("unsaved", { count: Object.keys(draft).length })}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (window.confirm(t("discardConfirm")))
|
||||
setDrafts((current) => ({ ...current, [activeLang]: {} }));
|
||||
}}
|
||||
>
|
||||
{t("discard")}
|
||||
</Button>
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<span role="alert" className="text-sm text-destructive">
|
||||
{t("fixErrors", { count: Object.keys(errors).length })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
)}
|
||||
<div className="divide-y rounded-lg border bg-card">
|
||||
{pageKeys.length === 0 && (
|
||||
<p className="p-6 text-sm text-muted-foreground">
|
||||
{t("noResults")}
|
||||
</p>
|
||||
)}
|
||||
{pageKeys.map((key) => (
|
||||
<div key={key} className="grid min-w-0 gap-2 p-4">
|
||||
<label
|
||||
htmlFor={`translation-${key}`}
|
||||
className="break-all font-mono text-xs text-muted-foreground"
|
||||
>
|
||||
{key}
|
||||
</label>
|
||||
{activeLang !== "en" && (
|
||||
<p className="whitespace-pre-wrap break-words text-sm">
|
||||
<span className="font-medium">{t("source")}: </span>
|
||||
{source[key]}
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
id={`translation-${key}`}
|
||||
aria-invalid={!!errors[key]}
|
||||
aria-describedby={errors[key] ? `error-${key}` : undefined}
|
||||
readOnly={!canEdit}
|
||||
value={valueFor(key)}
|
||||
onChange={(e) => updateValue(key, e.target.value)}
|
||||
placeholder={t("missingPlaceholder")}
|
||||
rows={2}
|
||||
className="w-full min-w-0 resize-y rounded-md border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
{errors[key] && (
|
||||
<p
|
||||
id={`error-${key}`}
|
||||
role="alert"
|
||||
className="break-words text-sm text-destructive"
|
||||
>
|
||||
{errors[key]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
{t("page", {
|
||||
page: currentPage,
|
||||
total: totalPages,
|
||||
count: filteredKeys.length,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage(currentPage - 1)}
|
||||
>
|
||||
Prev
|
||||
{t("previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setPage(currentPage + 1)}
|
||||
>
|
||||
Next
|
||||
{t("next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user