"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 { 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 { snapshots: Record; initialLocale: string; canEdit: boolean; } 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 [status, setStatus] = useState("all"); const [page, setPage] = useState(1); 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 }; }); } function handleSave() { if (!canEdit || !Object.keys(draft).length || Object.keys(errors).length) return; const locale = activeLang; run( () => saveTranslations({ locale, revision: saved[locale].revision, changes: draft, }), { 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, ...draft }), null, 2)], { type: "application/json" }, ); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${activeLang}.json`; a.click(); URL.revokeObjectURL(url); } return (

{t("title")}

{t("description")}

{canEdit && ( )}
{!canEdit && (

{t("readOnly")}

)}
{SUPPORTED_LOCALES.map((code) => ( ))}

{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 })} )}
)}
{pageKeys.length === 0 && (

{t("noResults")}

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

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

)}