feat(i18n): make admin and catalog components translatable

This commit is contained in:
openhands committed 2026-09-18 14:56:36 +02:00
1 parent d6111387e4
commit 03774bb411
9 files changed
+142 -87

No files matched your search

@@ -10,6 +10,7 @@ import {
Trash2,
X,
} from "lucide-react";
// import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
@@ -147,6 +147,7 @@ export function NitroEditorDialog({
onOpenChange,
}: NitroEditorDialogProps) {
const loadingT = useTranslations("pages.admin.housekeeping");
const t = useTranslations("pages.admin.import.furni.nitroEditorDialog");
const [metadata, setMetadata] = useState<NitroMetadata | null>(null);
const [originalMetadata, setOriginalMetadata] =
useState<NitroMetadata | null>(null);
@@ -191,7 +192,7 @@ export function NitroEditorDialog({
cansiton: result.canSit,
canlayon: result.canLay,
});
toast.success(`Auto-detected: ${result.reason}`);
toast.success(t("autoDetected", { reason: result.reason }));
};
const patchMeta = (path: string, value: unknown) => {
@@ -217,7 +218,7 @@ export function NitroEditorDialog({
const data = await res.json();
if (controller.signal.aborted) return;
if (!res.ok) {
toast.error(data.error || "Failed to load .nitro metadata");
toast.error(data.error || t("loadFailed"));
closeAfterLoadError();
return;
}
@@ -228,7 +229,7 @@ export function NitroEditorDialog({
setOriginalFlags({ ...data.flags });
} catch {
if (!controller.signal.aborted) {
toast.error("Network error loading .nitro metadata");
toast.error(t("networkErrorLoad"));
closeAfterLoadError();
}
} finally {
@@ -245,7 +246,7 @@ export function NitroEditorDialog({
setMetadata(parsed);
setJsonError(null);
} catch {
toast.error("Invalid JSON — fix it before switching tabs");
toast.error(t("invalidJsonSwitchTabs"));
return;
}
} else if (activeTab !== "json" && newTab === "json") {
@@ -260,7 +261,7 @@ export function NitroEditorDialog({
setFlags({ ...originalFlags });
setJsonText(JSON.stringify(originalMetadata, null, 2));
setJsonError(null);
toast.info("Reset to original values");
toast.info(t("resetToOriginal"));
};
const handleSave = async () => {
@@ -270,7 +271,7 @@ export function NitroEditorDialog({
try {
metadataToSave = JSON.parse(jsonText);
} catch {
toast.error("Invalid JSON");
toast.error(t("invalidJson"));
return;
}
}
@@ -289,7 +290,7 @@ export function NitroEditorDialog({
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err.error || "Save failed");
toast.error(err.error || t("saveFailed"));
return;
}
@@ -1,5 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import { AlertCircle, Check, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { toast } from "sonner";
@@ -22,6 +23,9 @@ interface UploadResult {
}
export function UploadFurniClient() {
const t = useTranslations(
"pages.admin.import.furni.upload.uploadFurniClient",
);
const nitroRef = useRef<HTMLInputElement>(null);
const iconRef = useRef<HTMLInputElement>(null);
@@ -73,20 +77,19 @@ export function UploadFurniClient() {
const trimmedClass = classname.trim();
if (!CLASSNAME_RE.test(trimmedClass)) {
const msg =
"Classname must contain only letters, numbers, and underscores";
const msg = t("errorClassname");
setError(msg);
toast.error(msg);
return;
}
if (!nitroFile) {
const msg = "Nitro file is required";
const msg = t("errorNitroRequired");
setError(msg);
toast.error(msg);
return;
}
if (nitroFile.size > NITRO_MAX_BYTES) {
const msg = "Nitro file must be 50MB or smaller";
const msg = t("errorNitroSize");
setError(msg);
toast.error(msg);
return;
@@ -11,6 +11,7 @@ import {
VolumeX,
Zap,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
assignCfhTicket,
@@ -36,16 +37,14 @@ import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
import { formatDate } from "@/lib/format-date";
const CFH_STATES: Record<number, { label: string; class: string }> = {
const CFH_STATES: Record<number, { class: string }> = {
0: {
label: "Open",
class: "bg-[var(--admin-error-subtle)] text-[var(--admin-error)]",
},
1: {
label: "Picked",
class: "bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]",
},
2: { label: "Closed", class: "bg-muted text-muted-foreground" },
2: { class: "bg-muted text-muted-foreground" },
};
interface UserInfo {
@@ -97,11 +96,18 @@ export function CfhDetail({
userLinkMode?: "admin" | "public";
}) {
void currentUserId;
const t = useTranslations("pages.admin.moderation.cfh.id.cfhDetail");
const { run, isPending } = useServerAction();
const [alertMsg, setAlertMsg] = useState("");
const [muteDuration, setMuteDuration] = useState(10);
const state = CFH_STATES[ticket.state] ?? CFH_STATES[0];
const stateKey =
ticket.state === 1
? "statePicked"
: ticket.state === 2
? "stateClosed"
: "stateOpen";
function userHref(user: UserInfo) {
if (userLinkMode === "public") {
@@ -122,17 +128,22 @@ export function CfhDetail({
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm text-muted-foreground font-mono">
CFH #{ticket.id}
{t("cfhId", { id: ticket.id })}
</span>
<Badge className={state.class}>{state.label}</Badge>
<Badge className={state.class}>{t(stateKey)}</Badge>
<span className="text-xs text-muted-foreground">
Type {ticket.type} · Category {ticket.category}
{t("typeCategory", {
type: ticket.type,
category: ticket.category,
})}
</span>
</div>
<h1 className="text-xl font-bold">Call For Help Report</h1>
<h1 className="text-xl font-bold">{t("title")}</h1>
<p className="text-xs text-muted-foreground">
{formatDate(new Date(ticket.timestamp * 1000))} · Room #
{ticket.roomId}
{t("roomMeta", {
when: formatDate(new Date(ticket.timestamp * 1000)),
room: ticket.roomId,
})}
</p>
</div>
</div>
@@ -145,12 +156,12 @@ export function CfhDetail({
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Report Message
{t("reportMessage")}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{ticket.issue || "No description provided."}
{ticket.issue || t("noDescription")}
</p>
</CardContent>
</Card>
File diff suppressed because it is too large. Load diff
+16 -12
View File
@@ -1,6 +1,7 @@
"use client";
import { Pencil, Plus, Save, Search, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import {
addBlacklistWord as addBlacklistWordAction,
@@ -35,6 +36,7 @@ import {
type UserPrefix,
} from "./prefix-model";
export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
const t = useTranslations("pages.admin.prefixes.prefixesClient");
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const [prefixes, setPrefixes] = useState<UserPrefix[]>([]);
@@ -93,14 +95,14 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
const handleDeletePrefix = async (id: number) => {
const ok = await confirm({
title: "Delete prefix",
description: "Are you sure you want to delete this prefix?",
confirmLabel: "Delete",
cancelLabel: "Cancel",
title: t("confirmDeleteTitle"),
description: t("confirmDeleteDesc"),
confirmLabel: t("confirmDelete"),
cancelLabel: t("cancel"),
});
if (!ok) return;
run(() => deletePrefixAction({ id }), {
successMessage: "Prefix deleted!",
successMessage: t("successPrefixDeleted"),
onSuccess: () => {
fetchPrefixes();
},
@@ -126,7 +128,9 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
? updatePrefix({ id: editId, ...fields })
: createPrefix({ username: data.username, ...fields }),
{
successMessage: editId ? "Prefix updated!" : "Prefix created!",
successMessage: editId
? t("successPrefixUpdated")
: t("successPrefixCreated"),
onSuccess: () => {
void fetchPrefixes();
resolve(true);
@@ -150,7 +154,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
const handleAddBlacklistWord = async () => {
if (!newWord.trim()) return;
run(() => addBlacklistWordAction({ word: newWord.trim() }), {
successMessage: "Word added to blacklist!",
successMessage: t("successWordAdded"),
onSuccess: () => {
setNewWord("");
fetchBlacklist();
@@ -160,7 +164,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
const handleRemoveBlacklistWord = async (id: number) => {
run(() => removeBlacklistWordAction({ id }), {
successMessage: "Word removed from blacklist!",
successMessage: t("successWordRemoved"),
onSuccess: () => {
fetchBlacklist();
},
@@ -174,7 +178,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
settings: settings as unknown as Record<string, string>,
}),
{
successMessage: "Settings saved!",
successMessage: t("successSettingsSaved"),
},
);
};
@@ -191,9 +195,9 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
<Tabs defaultValue="prefixes">
<TabsList>
<TabsTrigger value="prefixes">User Prefixes</TabsTrigger>
<TabsTrigger value="blacklist">Blacklist</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="prefixes">{t("tabUserPrefixes")}</TabsTrigger>
<TabsTrigger value="blacklist">{t("tabBlacklist")}</TabsTrigger>
<TabsTrigger value="settings">{t("tabSettings")}</TabsTrigger>
</TabsList>
<TabsContent value="prefixes" className="space-y-4">
+10 -6
View File
@@ -10,6 +10,7 @@ import {
Trash2,
Upload,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@@ -82,6 +83,7 @@ export function SoundsClient({
canEdit: boolean;
baseUrl: string;
}) {
const t = useTranslations("pages.admin.sounds.soundsClient");
const router = useRouter();
const { run, isPending } = useServerAction();
@@ -122,8 +124,8 @@ export function SoundsClient({
if (!deleting) return;
const id = deleting.id;
run(() => deleteSoundtrack({ id }), {
successMessage: `Song disk #${id} deleted.`,
errorMessage: `Unable to delete song disk #${id}.`,
successMessage: t("deleteSuccess", { id }),
errorMessage: t("deleteError", { id }),
onSuccess: () => {
invalidateSongPickerCache();
setDeleting(null);
@@ -140,7 +142,7 @@ export function SoundsClient({
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name, author, code or id…"
placeholder={t("searchPlaceholder")}
className="pl-8"
/>
</div>
@@ -152,12 +154,14 @@ export function SoundsClient({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All ({soundtracks.length})</SelectItem>
<SelectItem value="all">
{t("filterAll", { count: soundtracks.length })}
</SelectItem>
<SelectItem value="single">
Single MP3s ({counts.single})
{t("filterSingle", { count: counts.single })}
</SelectItem>
<SelectItem value="composition">
Composition ({counts.composition})
{t("filterComposition", { count: counts.composition })}
</SelectItem>
</SelectContent>
</Select>