style: format code biome
Local Build and Deploy / deploy (push) Failing after 46s

This commit is contained in:
openhands committed 2026-07-13 21:57:41 +02:00
1 parent 8efd032cc6
commit df38dccbf1
735 files changed
+128321 -120870

No files matched your search

@@ -8,221 +8,261 @@ 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 {
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;
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;
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]);
// 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 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);
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 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 updateValue(key: string, value: string) {
setData((d) => ({ ...d, [key]: value }));
}
function handleSave() {
if (activeFile.readOnly) return;
run(() => saveClientTranslations({ fileId: activeFile.id, data }), {
successMessage: "File salvato.",
onSuccess: (result) => {
if (result?.commentsLost) {
toast.warning(
"Alcune chiavi non corrispondevano al formato standard: file riserializzato e commenti rimossi.",
);
}
router.refresh();
},
});
}
function handleSave() {
if (activeFile.readOnly) return;
run(() => saveClientTranslations({ fileId: activeFile.id, data }), {
successMessage: "File salvato.",
onSuccess: (result) => {
if (result?.commentsLost) {
toast.warning(
"Alcune chiavi non corrispondevano al formato standard: file riserializzato e commenti rimossi.",
);
}
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);
}
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 items-center justify-between flex-wrap gap-2">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Gamepad2 className="w-6 h-6" />
Client (Nitro)
</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 || activeFile.readOnly || !!parseError}>
<Save className="w-4 h-4 mr-1" /> {isPending ? "Salvataggio..." : "Salva"}
</Button>
</div>
</div>
return (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Gamepad2 className="w-6 h-6" />
Client (Nitro)
</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 || activeFile.readOnly || !!parseError}
>
<Save className="w-4 h-4 mr-1" />{" "}
{isPending ? "Salvataggio..." : "Salva"}
</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>
<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="Cerca per chiave o valore..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="max-w-xs"
/>
<Input
placeholder="Cerca per chiave o valore..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="max-w-xs"
/>
<span className="text-sm text-muted-foreground">{filteredKeys.length} chiavi</span>
</div>
<span className="text-sm text-muted-foreground">
{filteredKeys.length} chiavi
</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" /> commenti preservati
</Badge>
)}
</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" /> commenti preservati
</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.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>
Questo file è di sola lettura.{" "}
{activeFile.note ?? "Modificarlo manualmente non è supportato dal CMS."}
</div>
</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>
Questo file è di sola lettura.{" "}
{activeFile.note ??
"Modificarlo manualmente non è supportato dal 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>
I commenti vengono preservati: il salvataggio modifica solo le righe dei valori cambiati. Se una
chiave usa un formato non standard ed è impossibile patchare in-place, il file viene
riserializzato (commenti persi) e ti avviseremo.
</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>
I commenti vengono preservati: il salvataggio modifica solo le righe
dei valori cambiati. Se una chiave usa un formato non standard ed è
impossibile patchare in-place, il file viene riserializzato
(commenti persi) e ti avviseremo.
</div>
</div>
)}
{parseError && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
Errore di parsing: {parseError}
</div>
)}
{parseError && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
Errore di parsing: {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">Nessuna voce.</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>
<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">
Nessuna voce.
</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">
Pagina {page} di {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>
);
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Pagina {page} di {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>
);
}
+42 -36
View File
@@ -1,48 +1,54 @@
import fs from "node:fs/promises";
import path from "node:path";
import JSON5 from "json5";
import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from "@/lib/client-translation-files";
import {
CLIENT_TRANSLATION_FILES,
getClientTranslationFile,
} from "@/lib/client-translation-files";
import { ClientTranslations } from "./client-translations";
export default async function ClientTranslationsPage({
searchParams,
searchParams,
}: {
searchParams: Promise<Record<string, string | undefined>>;
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 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;
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" ? JSON5.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.";
}
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" ? JSON5.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}
/>
);
return (
<ClientTranslations
files={CLIENT_TRANSLATION_FILES}
activeFile={file}
entries={entries}
parseError={parseError}
/>
);
}
+33 -30
View File
@@ -3,39 +3,42 @@ 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",
"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(), "messages");
const messagesDir = path.join(process.cwd(), "messages");
const contents: Record<string, string> = {};
await Promise.all(
LOCALES.map(async (locale) => {
contents[locale] = await fs.readFile(path.join(messagesDir, `${locale}.json`), "utf-8");
}),
);
const contents: Record<string, string> = {};
await Promise.all(
LOCALES.map(async (locale) => {
contents[locale] = await fs.readFile(
path.join(messagesDir, `${locale}.json`),
"utf-8",
);
}),
);
return <TranslationsClient localeContents={contents} />;
return <TranslationsClient localeContents={contents} />;
}
+82 -81
View File
@@ -1,6 +1,6 @@
import { getTranslations } from "next-intl/server";
import { ExternalLink, Server } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { EmulatorSettingsForm } from "@/components/admin/emulator-settings-form";
import { Button } from "@/components/ui/button";
import type { Prisma } from "@/generated/prisma/client";
@@ -8,98 +8,99 @@ import { calcPagination } from "@/lib/admin-helpers";
import { prisma } from "@/lib/prisma";
const TRANSLATION_KEY_PATTERNS = [
"commands.description.%",
"commands.keys.%",
"commands.cmd_%",
"wordfilter.%",
"pet.%message%",
"matchmaking.%message%",
"message.%",
"notification.%",
"commands.description.%",
"commands.keys.%",
"commands.cmd_%",
"wordfilter.%",
"pet.%message%",
"matchmaking.%message%",
"message.%",
"notification.%",
] as const;
export default async function EmulatorTranslationsPage({
searchParams,
searchParams,
}: {
searchParams: Promise<Record<string, string | undefined>>;
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 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: Prisma.EmulatorSettingsWhereInput[] = TRANSLATION_KEY_PATTERNS.map((p) => ({
key: { contains: p.replace(/%/g, "") },
}));
const patternFilters: Prisma.EmulatorSettingsWhereInput[] =
TRANSLATION_KEY_PATTERNS.map((p) => ({
key: { contains: p.replace(/%/g, "") },
}));
const baseFilter: Prisma.EmulatorSettingsWhereInput = { OR: patternFilters };
const baseFilter: Prisma.EmulatorSettingsWhereInput = { OR: patternFilters };
const conditions: Prisma.EmulatorSettingsWhereInput[] = [baseFilter];
if (group) conditions.push({ key: { startsWith: `${group}.` } });
if (search) {
conditions.push({
OR: [{ key: { contains: search } }, { value: { contains: search } }],
});
}
const where: Prisma.EmulatorSettingsWhereInput = { AND: conditions };
const conditions: Prisma.EmulatorSettingsWhereInput[] = [baseFilter];
if (group) conditions.push({ key: { startsWith: `${group}.` } });
if (search) {
conditions.push({
OR: [{ key: { contains: search } }, { value: { contains: search } }],
});
}
const where: Prisma.EmulatorSettingsWhereInput = { AND: conditions };
const [settings, total, allKeys] = await Promise.all([
prisma.emulatorSettings.findMany({
where,
orderBy: { key: "asc" },
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.emulatorSettings.count({ where }),
prisma.emulatorSettings.findMany({
where: baseFilter,
select: { key: true },
}),
]);
const [settings, total, allKeys] = await Promise.all([
prisma.emulatorSettings.findMany({
where,
orderBy: { key: "asc" },
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.emulatorSettings.count({ where }),
prisma.emulatorSettings.findMany({
where: baseFilter,
select: { key: true },
}),
]);
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 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 }));
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 items-start justify-between flex-wrap gap-2">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Server className="w-6 h-6" />
{t("title")}
</h1>
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
<Button asChild variant="outline" size="sm">
<Link href="/admin/settings/emulator">
<ExternalLink className="w-4 h-4 mr-1" />
{t("fullSettings")}
</Link>
</Button>
</div>
return (
<div className="space-y-4">
<div className="flex items-start justify-between flex-wrap gap-2">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Server className="w-6 h-6" />
{t("title")}
</h1>
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
<Button asChild variant="outline" size="sm">
<Link href="/admin/settings/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>
<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>
);
<EmulatorSettingsForm
settings={settingsMap}
pagination={pagination}
search={search}
allGroups={allGroups}
activeGroup={group}
/>
</div>
);
}
+15 -11
View File
@@ -2,16 +2,20 @@ 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");
}
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>
);
return (
<div className="space-y-6">
<TranslationsTabs />
{children}
</div>
);
}
+27 -24
View File
@@ -1,28 +1,31 @@
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 }).map((_, i) => (
<div key={i} 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>
);
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 }).map((_, i) => (
<div
key={i}
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 -1
View File
@@ -3,5 +3,5 @@ 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");
redirect("/admin/translations/cms");
}
+213 -192
View File
@@ -9,215 +9,236 @@ 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";
| "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" },
{ 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>;
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 [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 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 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 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);
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 updateValue(key: string, value: string) {
setData({ ...data, [activeLang]: { ...activeData, [key]: value } });
}
function handleSave() {
run(() => saveTranslations({ locale: activeLang, data: unflattenObject(activeData) }), {
successMessage: "Translations saved!",
});
}
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);
}
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>
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 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="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>
<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>
);
{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,42 +1,47 @@
"use client";
import { useTranslations } from "next-intl";
import { Gamepad2, Globe, Server } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
export function TranslationsTabs() {
const t = useTranslations("pages.admin.translations.tabs");
const pathname = usePathname();
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;
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>
);
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>
);
}