201 lines
6.4 KiB
TypeScript
201 lines
6.4 KiB
TypeScript
"use client";
|
|
|
|
import { Download, Globe, Save } from "lucide-react";
|
|
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" },
|
|
];
|
|
|
|
interface Props {
|
|
localeContents: Record<string, string>;
|
|
}
|
|
|
|
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 [search, setSearch] = useState("");
|
|
const [group, setGroup] = useState("");
|
|
const [activeLang, setActiveLang] = useState<Locale>("en");
|
|
const { run, isPending } = useServerAction();
|
|
const PER_PAGE = 30;
|
|
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;
|
|
});
|
|
}, [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() {
|
|
run(() => saveTranslations({ locale: activeLang, data: unflattenObject(activeData) }), {
|
|
successMessage: "Translations saved!",
|
|
});
|
|
}
|
|
|
|
function handleExport() {
|
|
const blob = new Blob([JSON.stringify(unflattenObject(activeData), 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 (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
|
<Globe className="w-6 h-6" />
|
|
CMS
|
|
</h1>
|
|
<div className="flex 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"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2 flex-wrap">
|
|
<div className="flex gap-1">
|
|
{LOCALES.map(({ code, label }) => (
|
|
<Button
|
|
key={code}
|
|
variant={activeLang === code ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => {
|
|
setActiveLang(code);
|
|
setPage(1);
|
|
}}
|
|
>
|
|
{label}
|
|
</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>
|
|
<Input
|
|
value={activeData[key]}
|
|
onChange={(e) => updateValue(key, e.target.value)}
|
|
className="flex-1 h-8 text-sm"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">
|
|
Page {page} of {totalPages}
|
|
</span>
|
|
<div className="flex gap-1">
|
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
|
Prev
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page >= totalPages}
|
|
onClick={() => setPage(page + 1)}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|