This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user