Add admin import overview page and extend CMS translations to all 6 locales
Local Build and Deploy / deploy (push) Successful in 51s
Local Build and Deploy / deploy (push) Successful in 51s
This commit is contained in:
1 parent
2e4ed76121
commit
6933fb77d5
4 files changed
+100
-29
No files matched your search
@@ -10,7 +10,7 @@ import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
const saveTranslationsSchema = z.object({
|
||||
locale: z.enum(["en", "it"]),
|
||||
locale: z.enum(["en", "it", "nl", "de", "fr", "es"]),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { BadgeCheck, Cat, Copy, Dumbbell, Puzzle, Shirt, Wrench } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
const SECTIONS = [
|
||||
{ href: "/admin/import/badges", label: "Badges", icon: BadgeCheck, description: "Import and manage badge assets from external sources." },
|
||||
{ href: "/admin/import/furni", label: "Furni", icon: Puzzle, description: "Import furniture definitions, SWF files, and icons." },
|
||||
{ href: "/admin/import/clothing", label: "Clothing", icon: Shirt, description: "Import clothing and wearables for avatars." },
|
||||
{ href: "/admin/import/effects", label: "Effects", icon: Dumbbell, description: "Import avatar effects and animations." },
|
||||
{ href: "/admin/import/pets", label: "Pets", icon: Cat, description: "Import pet data and icons." },
|
||||
{ href: "/admin/import/clone", label: "Clone", icon: Copy, description: "Clone existing items from one source to another." },
|
||||
{ href: "/admin/import/repair", label: "Repair", icon: Wrench, description: "Audit and repair imported assets." },
|
||||
] as const;
|
||||
|
||||
export default async function ImportPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Import</h1>
|
||||
<p className="text-muted-foreground">Import assets from external sources into your hotel.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{SECTIONS.map(({ href, label, icon: Icon, description }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className="group rounded-lg border bg-card p-4 transition-colors hover:border-primary hover:bg-accent"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-md bg-primary/10 p-2 text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold group-hover:text-primary">{label}</h2>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,17 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { TranslationsClient } from "../translations-client";
|
||||
|
||||
const LOCALES = ["en", "it", "nl", "de", "fr", "es"] as const;
|
||||
|
||||
export default async function CmsTranslationsPage() {
|
||||
const messagesDir = path.join(process.cwd(), "messages");
|
||||
const enContent = await fs.readFile(path.join(messagesDir, "en.json"), "utf-8");
|
||||
const itContent = await fs.readFile(path.join(messagesDir, "it.json"), "utf-8");
|
||||
|
||||
return <TranslationsClient enJson={enContent} itJson={itContent} />;
|
||||
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} />;
|
||||
}
|
||||
@@ -8,34 +8,49 @@ 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";
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
enJson: string;
|
||||
itJson: string;
|
||||
localeContents: Record<string, string>;
|
||||
}
|
||||
|
||||
export function TranslationsClient({ enJson, itJson }: Props) {
|
||||
const [en, setEn] = useState(() => flattenObject(JSON.parse(enJson)));
|
||||
const [it, setIt] = useState(() => flattenObject(JSON.parse(itJson)));
|
||||
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<"en" | "it">("en");
|
||||
const [activeLang, setActiveLang] = useState<Locale>("en");
|
||||
const { run, isPending } = useServerAction();
|
||||
const PER_PAGE = 30;
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const activeData = activeLang === "en" ? en : it;
|
||||
const setActiveData = activeLang === "en" ? setEn : setIt;
|
||||
const activeData = data[activeLang];
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const g = new Set<string>();
|
||||
Object.keys(en).forEach((k) => {
|
||||
Object.keys(data["en"] ?? {}).forEach((k) => {
|
||||
const parts = k.split(".");
|
||||
if (parts.length > 1) g.add(parts[0]);
|
||||
});
|
||||
return Array.from(g).sort();
|
||||
}, [en]);
|
||||
}, [data]);
|
||||
|
||||
const filteredKeys = useMemo(() => {
|
||||
if (!activeData) return [];
|
||||
return Object.keys(activeData).filter((key) => {
|
||||
const matchSearch =
|
||||
!search ||
|
||||
@@ -50,7 +65,7 @@ export function TranslationsClient({ enJson, itJson }: Props) {
|
||||
const pageKeys = filteredKeys.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
||||
|
||||
function updateValue(key: string, value: string) {
|
||||
setActiveData({ ...activeData, [key]: value });
|
||||
setData({ ...data, [activeLang]: { ...activeData, [key]: value } });
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
@@ -90,20 +105,19 @@ export function TranslationsClient({ enJson, itJson }: Props) {
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={activeLang === "en" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setActiveLang("en")}
|
||||
>
|
||||
EN
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeLang === "it" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setActiveLang("it")}
|
||||
>
|
||||
IT
|
||||
</Button>
|
||||
{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..."
|
||||
|
||||
Reference in new issue
Block a user