feat(housekeeping): cut over administration to ase
This commit is contained in:
1 parent
c9e35cf602
commit
2b8f73a91d
457 files changed
+589
-55936
No files matched your search
@@ -1,261 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Info, Lock, Save } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { saveClientTranslations } from "@/actions/translations";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import type { ClientTranslationFile } from "@/lib/client-translation-files";
|
||||
|
||||
interface Props {
|
||||
files: ClientTranslationFile[];
|
||||
activeFile: ClientTranslationFile;
|
||||
entries: Record<string, string>;
|
||||
parseError: string | null;
|
||||
}
|
||||
|
||||
export function ClientTranslations({
|
||||
files,
|
||||
activeFile,
|
||||
entries,
|
||||
parseError,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [data, setData] = useState<Record<string, string>>(entries);
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const { run, isPending } = useServerAction();
|
||||
const PER_PAGE = 50;
|
||||
|
||||
// Re-sync local state when the user switches file (server re-renders with
|
||||
// fresh entries; useState only initializes once).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
setData(entries);
|
||||
setSearch("");
|
||||
setPage(1);
|
||||
}, [entries, activeFile.id]);
|
||||
|
||||
const filteredKeys = useMemo(() => {
|
||||
const keys = Object.keys(data);
|
||||
if (!search) return keys;
|
||||
const q = search.toLowerCase();
|
||||
return keys.filter(
|
||||
(k) => k.toLowerCase().includes(q) || data[k].toLowerCase().includes(q),
|
||||
);
|
||||
}, [data, search]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredKeys.length / PER_PAGE));
|
||||
const pageKeys = filteredKeys.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
||||
|
||||
function changeFile(id: string) {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
params.set("file", id);
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
}
|
||||
|
||||
function updateValue(key: string, value: string) {
|
||||
setData((d) => ({ ...d, [key]: value }));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (activeFile.readOnly) return;
|
||||
run(() => saveClientTranslations({ fileId: activeFile.id, data }), {
|
||||
successMessage: "File saved.",
|
||||
onSuccess: (result) => {
|
||||
if (result?.commentsLost) {
|
||||
toast.warning(
|
||||
"Some keys did not match the standard format: file was reserialized and comments were removed.",
|
||||
);
|
||||
}
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = activeFile.relPath.split("/").pop() ?? "translations.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end flex-wrap gap-2">
|
||||
<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 || activeFile.readOnly || !!parseError}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1" /> {isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 items-center flex-wrap">
|
||||
<Select value={activeFile.id} onValueChange={changeFile}>
|
||||
<SelectTrigger className="w-[360px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{files.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs">
|
||||
{f.relPath.replace("public/", "")}
|
||||
</span>
|
||||
{f.readOnly && (
|
||||
<span className="text-[10px] uppercase text-muted-foreground">
|
||||
RO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
placeholder="Search by key or value..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{filteredKeys.length} keys
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="font-mono text-[11px]">
|
||||
{activeFile.format.toUpperCase()}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{activeFile.language === "shared"
|
||||
? "shared"
|
||||
: activeFile.language.toUpperCase()}
|
||||
</Badge>
|
||||
{activeFile.readOnly && (
|
||||
<Badge variant="secondary" className="gap-1 text-[11px]">
|
||||
<Lock className="w-3 h-3" /> read-only
|
||||
</Badge>
|
||||
)}
|
||||
{activeFile.hasComments && !activeFile.readOnly && (
|
||||
<Badge variant="secondary" className="gap-1 text-[11px]">
|
||||
<Info className="w-3 h-3" /> comments preserved
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeFile.note && (
|
||||
<div className="rounded-md border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
{activeFile.note}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeFile.readOnly && (
|
||||
<div className="rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-3 py-2 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)] flex items-start gap-2">
|
||||
<Lock className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
This file is read-only.{" "}
|
||||
{activeFile.note ?? "Manual editing is not supported by the CMS."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeFile.hasComments && !activeFile.readOnly && (
|
||||
<div className="rounded-md border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-3 py-2 text-xs text-[var(--admin-info)] dark:text-[var(--admin-info)] flex items-start gap-2">
|
||||
<Info className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
Comments are preserved: saving only patches changed value lines. If
|
||||
a key uses a non-standard format and cannot be patched in-place, the
|
||||
file is reserialized (comments lost) and you will be warned.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parseError && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
Parse error: {parseError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-card divide-y">
|
||||
{pageKeys.length === 0 && !parseError && (
|
||||
<div className="px-4 py-6 text-sm text-muted-foreground text-center">
|
||||
No entries.
|
||||
</div>
|
||||
)}
|
||||
{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-72 truncate flex-shrink-0"
|
||||
title={key}
|
||||
>
|
||||
{key}
|
||||
</span>
|
||||
<Input
|
||||
value={data[key]}
|
||||
onChange={(e) => updateValue(key, e.target.value)}
|
||||
className="flex-1 h-8 text-sm"
|
||||
disabled={activeFile.readOnly}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import * as JSONC from "jsonc-parser";
|
||||
import {
|
||||
CLIENT_TRANSLATION_FILES,
|
||||
getClientTranslationFile,
|
||||
} from "@/lib/client-translation-files";
|
||||
import { ClientTranslations } from "./client-translations";
|
||||
|
||||
export default async function ClientTranslationsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | undefined>>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const requested = sp.file;
|
||||
const file =
|
||||
(requested && getClientTranslationFile(requested)) ||
|
||||
CLIENT_TRANSLATION_FILES.find((f) => !f.readOnly) ||
|
||||
CLIENT_TRANSLATION_FILES[0];
|
||||
|
||||
const entries: Record<string, string> = {};
|
||||
let parseError: string | null = null;
|
||||
|
||||
try {
|
||||
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
|
||||
// Turbopack's tracer can't prove that — hint avoids tracing the whole project.
|
||||
const raw = await fs.readFile(
|
||||
path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath),
|
||||
"utf-8",
|
||||
);
|
||||
const parsed = file.format === "json5" ? JSONC.parse(raw) : JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
// Nitro localization files are flat: { "key.with.dots": "value" }.
|
||||
// Coerce values to strings so the editor can render them in <input>.
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
entries[k] = v == null ? "" : String(v);
|
||||
}
|
||||
} else {
|
||||
parseError = "File root is not an object.";
|
||||
}
|
||||
} catch (err) {
|
||||
parseError = err instanceof Error ? err.message : "Failed to read file.";
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientTranslations
|
||||
files={CLIENT_TRANSLATION_FILES}
|
||||
activeFile={file}
|
||||
entries={entries}
|
||||
parseError={parseError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
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.
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return <TranslationsClient localeContents={contents} />;
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { and, asc, count, like, or, type SQL, sql } from "drizzle-orm";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { EmulatorSettingsForm } from "@/components/admin/emulator-settings-form";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { calcPagination } from "@/lib/admin-helpers";
|
||||
import { db, EmulatorSettings } from "@/lib/db";
|
||||
|
||||
const TRANSLATION_KEY_PATTERNS = [
|
||||
"commands.description.",
|
||||
"commands.keys.",
|
||||
"commands.cmd_",
|
||||
"wordfilter.",
|
||||
"pet.",
|
||||
"matchmaking.",
|
||||
"message.",
|
||||
"notification.",
|
||||
] as const;
|
||||
|
||||
export default async function EmulatorTranslationsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | undefined>>;
|
||||
}) {
|
||||
const t = await getTranslations("pages.admin.translations.emulator");
|
||||
const sp = await searchParams;
|
||||
const search = sp.search || "";
|
||||
const group = sp.group || "";
|
||||
const perPage = Math.min(Math.max(parseInt(sp.perPage || "50", 10), 10), 200);
|
||||
const page = Math.max(parseInt(sp.page || "1", 10), 1);
|
||||
|
||||
const patternFilters =
|
||||
or(
|
||||
...TRANSLATION_KEY_PATTERNS.map((p) =>
|
||||
like(EmulatorSettings.key, `${p}%`),
|
||||
),
|
||||
) ?? sql`1=0`;
|
||||
|
||||
const conditions: SQL[] = [patternFilters];
|
||||
if (group) conditions.push(like(EmulatorSettings.key, `${group}.%`));
|
||||
if (search) {
|
||||
const searchFilter = or(
|
||||
like(EmulatorSettings.key, `%${search}%`),
|
||||
like(EmulatorSettings.value, `%${search}%`),
|
||||
);
|
||||
if (searchFilter) conditions.push(searchFilter);
|
||||
}
|
||||
const where = and(...conditions);
|
||||
|
||||
const [settings, totalRows, allKeys] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(EmulatorSettings)
|
||||
.where(where)
|
||||
.orderBy(asc(EmulatorSettings.key))
|
||||
.limit(perPage)
|
||||
.offset((page - 1) * perPage),
|
||||
db.select({ total: count() }).from(EmulatorSettings).where(where),
|
||||
db
|
||||
.select({ key: EmulatorSettings.key })
|
||||
.from(EmulatorSettings)
|
||||
.where(patternFilters),
|
||||
]);
|
||||
|
||||
const total = Number(totalRows[0]?.total ?? 0);
|
||||
|
||||
const groupCounts = new Map<string, number>();
|
||||
for (const { key } of allKeys) {
|
||||
const g = key.includes(".") ? key.split(".")[0] : "general";
|
||||
groupCounts.set(g, (groupCounts.get(g) ?? 0) + 1);
|
||||
}
|
||||
const allGroups = Array.from(groupCounts.entries())
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const pagination = calcPagination(total, page, perPage);
|
||||
const settingsMap = settings.map((s) => ({ key: s.key, value: s.value }));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/admin/emulator">
|
||||
<ExternalLink className="w-4 h-4 mr-1" />
|
||||
{t("fullSettings")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
{t("infoBox")}
|
||||
</div>
|
||||
|
||||
<EmulatorSettingsForm
|
||||
settings={settingsMap}
|
||||
pagination={pagination}
|
||||
search={search}
|
||||
allGroups={allGroups}
|
||||
activeGroup={group}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { TranslationsTabs } from "./translations-tabs";
|
||||
|
||||
export default async function TranslationsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<TranslationsTabs />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function TranslationsLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-5 w-64 mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
{Array.from({ length: 8 }, (_, i) => i).map((n) => (
|
||||
<div
|
||||
key={`skeleton-row-${n}`}
|
||||
className="flex items-center gap-6 px-4 py-3 border-b last:border-0"
|
||||
>
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// Default landing for /admin/translations → first tab.
|
||||
// The shared TranslationsTabs (in layout.tsx) gives access to every section.
|
||||
export default function TranslationsHubPage() {
|
||||
redirect("/admin/translations/cms");
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Download, 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 justify-end">
|
||||
<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 overflow-x-auto pb-1 max-w-full">
|
||||
{LOCALES.map(({ code, label }) => (
|
||||
<Button
|
||||
key={code}
|
||||
variant={activeLang === code ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Gamepad2, Globe, Server } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "@/components/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function TranslationsTabs() {
|
||||
const t = useTranslations("pages.admin.translations.tabs");
|
||||
const pathname = usePathname();
|
||||
|
||||
const TABS = [
|
||||
{ href: "/admin/translations/cms", label: t("cms"), icon: Globe },
|
||||
{ href: "/admin/translations/client", label: t("client"), icon: Gamepad2 },
|
||||
{
|
||||
href: "/admin/translations/emulator",
|
||||
label: t("emulator"),
|
||||
icon: Server,
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 border-b">
|
||||
{TABS.map((tab) => {
|
||||
const active =
|
||||
pathname === tab.href || pathname.startsWith(`${tab.href}/`);
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={cn(
|
||||
"-mb-px flex items-center gap-2 border-b-2 px-4 py-2 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user