feat(i18n): make admin and catalog components translatable
This commit is contained in:
1 parent
d6111387e4
commit
03774bb411
9 files changed
+142
-87
No files matched your search
@@ -10,6 +10,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
// import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export function NitroEditorDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: NitroEditorDialogProps) {
|
}: NitroEditorDialogProps) {
|
||||||
const loadingT = useTranslations("pages.admin.housekeeping");
|
const loadingT = useTranslations("pages.admin.housekeeping");
|
||||||
|
const t = useTranslations("pages.admin.import.furni.nitroEditorDialog");
|
||||||
const [metadata, setMetadata] = useState<NitroMetadata | null>(null);
|
const [metadata, setMetadata] = useState<NitroMetadata | null>(null);
|
||||||
const [originalMetadata, setOriginalMetadata] =
|
const [originalMetadata, setOriginalMetadata] =
|
||||||
useState<NitroMetadata | null>(null);
|
useState<NitroMetadata | null>(null);
|
||||||
@@ -191,7 +192,7 @@ export function NitroEditorDialog({
|
|||||||
cansiton: result.canSit,
|
cansiton: result.canSit,
|
||||||
canlayon: result.canLay,
|
canlayon: result.canLay,
|
||||||
});
|
});
|
||||||
toast.success(`Auto-detected: ${result.reason}`);
|
toast.success(t("autoDetected", { reason: result.reason }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const patchMeta = (path: string, value: unknown) => {
|
const patchMeta = (path: string, value: unknown) => {
|
||||||
@@ -217,7 +218,7 @@ export function NitroEditorDialog({
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast.error(data.error || "Failed to load .nitro metadata");
|
toast.error(data.error || t("loadFailed"));
|
||||||
closeAfterLoadError();
|
closeAfterLoadError();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -228,7 +229,7 @@ export function NitroEditorDialog({
|
|||||||
setOriginalFlags({ ...data.flags });
|
setOriginalFlags({ ...data.flags });
|
||||||
} catch {
|
} catch {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
toast.error("Network error loading .nitro metadata");
|
toast.error(t("networkErrorLoad"));
|
||||||
closeAfterLoadError();
|
closeAfterLoadError();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -245,7 +246,7 @@ export function NitroEditorDialog({
|
|||||||
setMetadata(parsed);
|
setMetadata(parsed);
|
||||||
setJsonError(null);
|
setJsonError(null);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Invalid JSON — fix it before switching tabs");
|
toast.error(t("invalidJsonSwitchTabs"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (activeTab !== "json" && newTab === "json") {
|
} else if (activeTab !== "json" && newTab === "json") {
|
||||||
@@ -260,7 +261,7 @@ export function NitroEditorDialog({
|
|||||||
setFlags({ ...originalFlags });
|
setFlags({ ...originalFlags });
|
||||||
setJsonText(JSON.stringify(originalMetadata, null, 2));
|
setJsonText(JSON.stringify(originalMetadata, null, 2));
|
||||||
setJsonError(null);
|
setJsonError(null);
|
||||||
toast.info("Reset to original values");
|
toast.info(t("resetToOriginal"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -270,7 +271,7 @@ export function NitroEditorDialog({
|
|||||||
try {
|
try {
|
||||||
metadataToSave = JSON.parse(jsonText);
|
metadataToSave = JSON.parse(jsonText);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Invalid JSON");
|
toast.error(t("invalidJson"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,7 +290,7 @@ export function NitroEditorDialog({
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
toast.error(err.error || "Save failed");
|
toast.error(err.error || t("saveFailed"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { AlertCircle, Check, Loader2, Upload } from "lucide-react";
|
import { AlertCircle, Check, Loader2, Upload } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -22,6 +23,9 @@ interface UploadResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UploadFurniClient() {
|
export function UploadFurniClient() {
|
||||||
|
const t = useTranslations(
|
||||||
|
"pages.admin.import.furni.upload.uploadFurniClient",
|
||||||
|
);
|
||||||
const nitroRef = useRef<HTMLInputElement>(null);
|
const nitroRef = useRef<HTMLInputElement>(null);
|
||||||
const iconRef = useRef<HTMLInputElement>(null);
|
const iconRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -73,20 +77,19 @@ export function UploadFurniClient() {
|
|||||||
|
|
||||||
const trimmedClass = classname.trim();
|
const trimmedClass = classname.trim();
|
||||||
if (!CLASSNAME_RE.test(trimmedClass)) {
|
if (!CLASSNAME_RE.test(trimmedClass)) {
|
||||||
const msg =
|
const msg = t("errorClassname");
|
||||||
"Classname must contain only letters, numbers, and underscores";
|
|
||||||
setError(msg);
|
setError(msg);
|
||||||
toast.error(msg);
|
toast.error(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!nitroFile) {
|
if (!nitroFile) {
|
||||||
const msg = "Nitro file is required";
|
const msg = t("errorNitroRequired");
|
||||||
setError(msg);
|
setError(msg);
|
||||||
toast.error(msg);
|
toast.error(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (nitroFile.size > NITRO_MAX_BYTES) {
|
if (nitroFile.size > NITRO_MAX_BYTES) {
|
||||||
const msg = "Nitro file must be 50MB or smaller";
|
const msg = t("errorNitroSize");
|
||||||
setError(msg);
|
setError(msg);
|
||||||
toast.error(msg);
|
toast.error(msg);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
VolumeX,
|
VolumeX,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
assignCfhTicket,
|
assignCfhTicket,
|
||||||
@@ -36,16 +37,14 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { useServerAction } from "@/hooks/use-server-action";
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
|
||||||
const CFH_STATES: Record<number, { label: string; class: string }> = {
|
const CFH_STATES: Record<number, { class: string }> = {
|
||||||
0: {
|
0: {
|
||||||
label: "Open",
|
|
||||||
class: "bg-[var(--admin-error-subtle)] text-[var(--admin-error)]",
|
class: "bg-[var(--admin-error-subtle)] text-[var(--admin-error)]",
|
||||||
},
|
},
|
||||||
1: {
|
1: {
|
||||||
label: "Picked",
|
|
||||||
class: "bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]",
|
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 {
|
interface UserInfo {
|
||||||
@@ -97,11 +96,18 @@ export function CfhDetail({
|
|||||||
userLinkMode?: "admin" | "public";
|
userLinkMode?: "admin" | "public";
|
||||||
}) {
|
}) {
|
||||||
void currentUserId;
|
void currentUserId;
|
||||||
|
const t = useTranslations("pages.admin.moderation.cfh.id.cfhDetail");
|
||||||
const { run, isPending } = useServerAction();
|
const { run, isPending } = useServerAction();
|
||||||
const [alertMsg, setAlertMsg] = useState("");
|
const [alertMsg, setAlertMsg] = useState("");
|
||||||
const [muteDuration, setMuteDuration] = useState(10);
|
const [muteDuration, setMuteDuration] = useState(10);
|
||||||
|
|
||||||
const state = CFH_STATES[ticket.state] ?? CFH_STATES[0];
|
const state = CFH_STATES[ticket.state] ?? CFH_STATES[0];
|
||||||
|
const stateKey =
|
||||||
|
ticket.state === 1
|
||||||
|
? "statePicked"
|
||||||
|
: ticket.state === 2
|
||||||
|
? "stateClosed"
|
||||||
|
: "stateOpen";
|
||||||
|
|
||||||
function userHref(user: UserInfo) {
|
function userHref(user: UserInfo) {
|
||||||
if (userLinkMode === "public") {
|
if (userLinkMode === "public") {
|
||||||
@@ -122,17 +128,22 @@ export function CfhDetail({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-sm text-muted-foreground font-mono">
|
<span className="text-sm text-muted-foreground font-mono">
|
||||||
CFH #{ticket.id}
|
{t("cfhId", { id: ticket.id })}
|
||||||
</span>
|
</span>
|
||||||
<Badge className={state.class}>{state.label}</Badge>
|
<Badge className={state.class}>{t(stateKey)}</Badge>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Type {ticket.type} · Category {ticket.category}
|
{t("typeCategory", {
|
||||||
|
type: ticket.type,
|
||||||
|
category: ticket.category,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<p className="text-xs text-muted-foreground">
|
||||||
{formatDate(new Date(ticket.timestamp * 1000))} · Room #
|
{t("roomMeta", {
|
||||||
{ticket.roomId}
|
when: formatDate(new Date(ticket.timestamp * 1000)),
|
||||||
|
room: ticket.roomId,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,12 +156,12 @@ export function CfhDetail({
|
|||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm flex items-center gap-2">
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
<MessageSquare className="h-4 w-4" />
|
<MessageSquare className="h-4 w-4" />
|
||||||
Report Message
|
{t("reportMessage")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
{ticket.issue || "No description provided."}
|
{ticket.issue || t("noDescription")}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +46,7 @@ export function RankEditClient({
|
|||||||
allCmsPermissions,
|
allCmsPermissions,
|
||||||
}: RankEditorProps) {
|
}: RankEditorProps) {
|
||||||
const { run, isPending } = useServerAction();
|
const { run, isPending } = useServerAction();
|
||||||
|
const t = useTranslations("pages.admin.permissions.id.rankEditClient");
|
||||||
|
|
||||||
const [general, setGeneral] = useState({
|
const [general, setGeneral] = useState({
|
||||||
rank_name: initialRank.rank_name,
|
rank_name: initialRank.rank_name,
|
||||||
@@ -89,7 +91,7 @@ export function RankEditClient({
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
run(() => saveRankAction({ id: initialRank.id, fields }), {
|
run(() => saveRankAction({ id: initialRank.id, fields }), {
|
||||||
successMessage: "Rank saved",
|
successMessage: t("rankSaved"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,11 +109,11 @@ export function RankEditClient({
|
|||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
setCmsPermSlugs(prev);
|
setCmsPermSlugs(prev);
|
||||||
toast.error("Failed");
|
toast.error(t("failed"));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setCmsPermSlugs(prev);
|
setCmsPermSlugs(prev);
|
||||||
toast.error("Failed");
|
toast.error(t("failed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +197,7 @@ export function RankEditClient({
|
|||||||
setSectionAll(filtered, "1");
|
setSectionAll(filtered, "1");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
All On
|
{t("allOn")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -206,7 +208,7 @@ export function RankEditClient({
|
|||||||
setSectionAll(filtered, "0");
|
setSectionAll(filtered, "0");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
All Off
|
{t("allOff")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -262,11 +264,14 @@ export function RankEditClient({
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">
|
<h1 className="text-2xl font-bold">
|
||||||
{general.rank_name || `Rank ${initialRank.id}`}
|
{general.rank_name || t("rankFallback", { id: initialRank.id })}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Rank #{initialRank.id} · Level {initialRank.level} ·{" "}
|
{t("rankMeta", {
|
||||||
{totalUsers} users
|
id: initialRank.id,
|
||||||
|
level: initialRank.level,
|
||||||
|
count: totalUsers,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,28 +280,31 @@ export function RankEditClient({
|
|||||||
<Tabs defaultValue="general">
|
<Tabs defaultValue="general">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="general">
|
<TabsTrigger value="general">
|
||||||
<Settings className="w-3.5 h-3.5 mr-1" /> General
|
<Settings className="w-3.5 h-3.5 mr-1" /> {t("tabGeneral")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="emulator">
|
<TabsTrigger value="emulator">
|
||||||
<Terminal className="w-3.5 h-3.5 mr-1" /> Emulator ({enabledCount})
|
<Terminal className="w-3.5 h-3.5 mr-1" />{" "}
|
||||||
|
{t("tabEmulator", { count: enabledCount })}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="cms">
|
<TabsTrigger value="cms">
|
||||||
<UserCheck className="w-3.5 h-3.5 mr-1" /> CMS (
|
<UserCheck className="w-3.5 h-3.5 mr-1" />{" "}
|
||||||
{cmsPermSlugs.length})
|
{t("tabCms", { count: cmsPermSlugs.length })}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="users">
|
||||||
|
{t("tabUsers", { count: totalUsers })}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="users">Users ({totalUsers})</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* ═══ General ═══ */}
|
{/* ═══ General ═══ */}
|
||||||
<TabsContent value="general" className="space-y-4">
|
<TabsContent value="general" className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Rank Settings</CardTitle>
|
<CardTitle className="text-base">{t("rankSettings")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Rank Name</Label>
|
<Label className="text-xs">{t("rankName")}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={general.rank_name}
|
value={general.rank_name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -305,7 +313,7 @@ export function RankEditClient({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Badge</Label>
|
<Label className="text-xs">{t("badge")}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={general.badge}
|
value={general.badge}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -314,7 +322,7 @@ export function RankEditClient({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Prefix</Label>
|
<Label className="text-xs">{t("prefix")}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={general.prefix}
|
value={general.prefix}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -323,7 +331,7 @@ export function RankEditClient({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Prefix Color</Label>
|
<Label className="text-xs">{t("prefixColor")}</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={general.prefix_color}
|
value={general.prefix_color}
|
||||||
@@ -348,10 +356,10 @@ export function RankEditClient({
|
|||||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="rank-hidden_rank" className="text-sm">
|
<Label htmlFor="rank-hidden_rank" className="text-sm">
|
||||||
Hidden Rank
|
{t("hiddenRank")}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Hide from public
|
{t("hideFromPublic")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -365,10 +373,10 @@ export function RankEditClient({
|
|||||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="rank-log_commands" className="text-sm">
|
<Label htmlFor="rank-log_commands" className="text-sm">
|
||||||
Log Commands
|
{t("logCommands")}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Record to audit log
|
{t("recordToAuditLog")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -380,7 +388,7 @@ export function RankEditClient({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 rounded-lg border p-3">
|
<div className="space-y-1.5 rounded-lg border p-3">
|
||||||
<Label className="text-xs">Room Effect ID</Label>
|
<Label className="text-xs">{t("roomEffectId")}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={general.room_effect}
|
value={general.room_effect}
|
||||||
@@ -390,7 +398,7 @@ export function RankEditClient({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Auto Credits</Label>
|
<Label className="text-xs">{t("autoCredits")}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={general.auto_credits_amount}
|
value={general.auto_credits_amount}
|
||||||
@@ -400,7 +408,7 @@ export function RankEditClient({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Auto Pixels</Label>
|
<Label className="text-xs">{t("autoPixels")}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={general.auto_pixels_amount}
|
value={general.auto_pixels_amount}
|
||||||
@@ -410,7 +418,7 @@ export function RankEditClient({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Auto GOTW</Label>
|
<Label className="text-xs">{t("autoGotw")}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={general.auto_gotw_amount}
|
value={general.auto_gotw_amount}
|
||||||
@@ -418,7 +426,7 @@ export function RankEditClient({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Auto Points</Label>
|
<Label className="text-xs">{t("autoPoints")}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={general.auto_points_amount}
|
value={general.auto_points_amount}
|
||||||
@@ -435,7 +443,7 @@ export function RankEditClient({
|
|||||||
) : (
|
) : (
|
||||||
<Save className="w-4 h-4 mr-1" />
|
<Save className="w-4 h-4 mr-1" />
|
||||||
)}
|
)}
|
||||||
Save
|
{t("save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -445,10 +453,13 @@ export function RankEditClient({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">
|
<CardTitle className="text-base">
|
||||||
Users with this rank
|
{t("usersWithThisRank")}
|
||||||
{totalUsers > users.length && (
|
{totalUsers > users.length && (
|
||||||
<span className="text-muted-foreground font-normal ml-1 text-sm">
|
<span className="text-muted-foreground font-normal ml-1 text-sm">
|
||||||
(showing {users.length} of {totalUsers})
|
{t("showingUsers", {
|
||||||
|
shown: users.length,
|
||||||
|
total: totalUsers,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -456,7 +467,7 @@ export function RankEditClient({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No users with this rank.
|
{t("noUsersWithThisRank")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
|
||||||
@@ -490,7 +501,10 @@ export function RankEditClient({
|
|||||||
<TabsContent value="emulator" className="space-y-4">
|
<TabsContent value="emulator" className="space-y-4">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{enabledCount} of {totalCount} permissions enabled
|
{t("permissionsEnabled", {
|
||||||
|
enabled: enabledCount,
|
||||||
|
total: totalCount,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
@@ -500,7 +514,7 @@ export function RankEditClient({
|
|||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
onClick={() => setAllPerms("1")}
|
onClick={() => setAllPerms("1")}
|
||||||
>
|
>
|
||||||
Enable All
|
{t("enableAll")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -508,36 +522,48 @@ export function RankEditClient({
|
|||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
onClick={() => setAllPerms("0")}
|
onClick={() => setAllPerms("0")}
|
||||||
>
|
>
|
||||||
Disable All
|
{t("disableAll")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 text-[11px] text-muted-foreground">
|
<div className="flex gap-2 text-[11px] text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="w-3 h-3 rounded bg-[var(--admin-text-muted)] inline-block" />{" "}
|
<span className="w-3 h-3 rounded bg-[var(--admin-text-muted)] inline-block" />{" "}
|
||||||
Off
|
{t("off")}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="w-3 h-3 rounded bg-[var(--admin-success-subtle)] inline-block" />{" "}
|
<span className="w-3 h-3 rounded bg-[var(--admin-success-subtle)] inline-block" />{" "}
|
||||||
On
|
{t("on")}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="w-3 h-3 rounded bg-[var(--admin-warning-subtle)] inline-block" />{" "}
|
<span className="w-3 h-3 rounded bg-[var(--admin-warning-subtle)] inline-block" />{" "}
|
||||||
Owner
|
{t("owner")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SearchInput
|
<SearchInput
|
||||||
placeholder="Search permissions..."
|
placeholder={t("searchPermissionsPlaceholder")}
|
||||||
value={permSearch}
|
value={permSearch}
|
||||||
onChange={setPermSearch}
|
onChange={setPermSearch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PermSection title="Commands" keys={cats.commands} sectionKey="cmd" />
|
<PermSection
|
||||||
<PermSection title="Account" keys={cats.account} sectionKey="acc" />
|
title={t("commands")}
|
||||||
|
keys={cats.commands}
|
||||||
|
sectionKey="cmd"
|
||||||
|
/>
|
||||||
|
<PermSection
|
||||||
|
title={t("account")}
|
||||||
|
keys={cats.account}
|
||||||
|
sectionKey="acc"
|
||||||
|
/>
|
||||||
{cats.other.length > 0 && (
|
{cats.other.length > 0 && (
|
||||||
<PermSection title="Other" keys={cats.other} sectionKey="other" />
|
<PermSection
|
||||||
|
title={t("other")}
|
||||||
|
keys={cats.other}
|
||||||
|
sectionKey="other"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
@@ -547,7 +573,7 @@ export function RankEditClient({
|
|||||||
) : (
|
) : (
|
||||||
<Save className="w-4 h-4 mr-1" />
|
<Save className="w-4 h-4 mr-1" />
|
||||||
)}
|
)}
|
||||||
Save Rank
|
{t("saveRank")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -663,10 +689,13 @@ export function RankEditClient({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">
|
<CardTitle className="text-base">
|
||||||
Users with this rank
|
{t("usersWithThisRank")}
|
||||||
{totalUsers > users.length && (
|
{totalUsers > users.length && (
|
||||||
<span className="text-muted-foreground font-normal ml-1 text-sm">
|
<span className="text-muted-foreground font-normal ml-1 text-sm">
|
||||||
(showing {users.length} of {totalUsers})
|
{t("showingUsers", {
|
||||||
|
shown: users.length,
|
||||||
|
total: totalUsers,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -674,7 +703,7 @@ export function RankEditClient({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No users with this rank.
|
{t("noUsersWithThisRank")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Pencil, Plus, Save, Search, Trash2 } from "lucide-react";
|
import { Pencil, Plus, Save, Search, Trash2 } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
addBlacklistWord as addBlacklistWordAction,
|
addBlacklistWord as addBlacklistWordAction,
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
type UserPrefix,
|
type UserPrefix,
|
||||||
} from "./prefix-model";
|
} from "./prefix-model";
|
||||||
export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
||||||
|
const t = useTranslations("pages.admin.prefixes.prefixesClient");
|
||||||
const { run, isPending } = useServerAction();
|
const { run, isPending } = useServerAction();
|
||||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||||
const [prefixes, setPrefixes] = useState<UserPrefix[]>([]);
|
const [prefixes, setPrefixes] = useState<UserPrefix[]>([]);
|
||||||
@@ -93,14 +95,14 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
|||||||
|
|
||||||
const handleDeletePrefix = async (id: number) => {
|
const handleDeletePrefix = async (id: number) => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: "Delete prefix",
|
title: t("confirmDeleteTitle"),
|
||||||
description: "Are you sure you want to delete this prefix?",
|
description: t("confirmDeleteDesc"),
|
||||||
confirmLabel: "Delete",
|
confirmLabel: t("confirmDelete"),
|
||||||
cancelLabel: "Cancel",
|
cancelLabel: t("cancel"),
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
run(() => deletePrefixAction({ id }), {
|
run(() => deletePrefixAction({ id }), {
|
||||||
successMessage: "Prefix deleted!",
|
successMessage: t("successPrefixDeleted"),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
fetchPrefixes();
|
fetchPrefixes();
|
||||||
},
|
},
|
||||||
@@ -126,7 +128,9 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
|||||||
? updatePrefix({ id: editId, ...fields })
|
? updatePrefix({ id: editId, ...fields })
|
||||||
: createPrefix({ username: data.username, ...fields }),
|
: createPrefix({ username: data.username, ...fields }),
|
||||||
{
|
{
|
||||||
successMessage: editId ? "Prefix updated!" : "Prefix created!",
|
successMessage: editId
|
||||||
|
? t("successPrefixUpdated")
|
||||||
|
: t("successPrefixCreated"),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void fetchPrefixes();
|
void fetchPrefixes();
|
||||||
resolve(true);
|
resolve(true);
|
||||||
@@ -150,7 +154,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
|||||||
const handleAddBlacklistWord = async () => {
|
const handleAddBlacklistWord = async () => {
|
||||||
if (!newWord.trim()) return;
|
if (!newWord.trim()) return;
|
||||||
run(() => addBlacklistWordAction({ word: newWord.trim() }), {
|
run(() => addBlacklistWordAction({ word: newWord.trim() }), {
|
||||||
successMessage: "Word added to blacklist!",
|
successMessage: t("successWordAdded"),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setNewWord("");
|
setNewWord("");
|
||||||
fetchBlacklist();
|
fetchBlacklist();
|
||||||
@@ -160,7 +164,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
|||||||
|
|
||||||
const handleRemoveBlacklistWord = async (id: number) => {
|
const handleRemoveBlacklistWord = async (id: number) => {
|
||||||
run(() => removeBlacklistWordAction({ id }), {
|
run(() => removeBlacklistWordAction({ id }), {
|
||||||
successMessage: "Word removed from blacklist!",
|
successMessage: t("successWordRemoved"),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
fetchBlacklist();
|
fetchBlacklist();
|
||||||
},
|
},
|
||||||
@@ -174,7 +178,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
|||||||
settings: settings as unknown as Record<string, string>,
|
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">
|
<Tabs defaultValue="prefixes">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="prefixes">User Prefixes</TabsTrigger>
|
<TabsTrigger value="prefixes">{t("tabUserPrefixes")}</TabsTrigger>
|
||||||
<TabsTrigger value="blacklist">Blacklist</TabsTrigger>
|
<TabsTrigger value="blacklist">{t("tabBlacklist")}</TabsTrigger>
|
||||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
<TabsTrigger value="settings">{t("tabSettings")}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="prefixes" className="space-y-4">
|
<TabsContent value="prefixes" className="space-y-4">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -82,6 +83,7 @@ export function SoundsClient({
|
|||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("pages.admin.sounds.soundsClient");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { run, isPending } = useServerAction();
|
const { run, isPending } = useServerAction();
|
||||||
|
|
||||||
@@ -122,8 +124,8 @@ export function SoundsClient({
|
|||||||
if (!deleting) return;
|
if (!deleting) return;
|
||||||
const id = deleting.id;
|
const id = deleting.id;
|
||||||
run(() => deleteSoundtrack({ id }), {
|
run(() => deleteSoundtrack({ id }), {
|
||||||
successMessage: `Song disk #${id} deleted.`,
|
successMessage: t("deleteSuccess", { id }),
|
||||||
errorMessage: `Unable to delete song disk #${id}.`,
|
errorMessage: t("deleteError", { id }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateSongPickerCache();
|
invalidateSongPickerCache();
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
@@ -140,7 +142,7 @@ export function SoundsClient({
|
|||||||
<Input
|
<Input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search by name, author, code or id…"
|
placeholder={t("searchPlaceholder")}
|
||||||
className="pl-8"
|
className="pl-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,12 +154,14 @@ export function SoundsClient({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All ({soundtracks.length})</SelectItem>
|
<SelectItem value="all">
|
||||||
|
{t("filterAll", { count: soundtracks.length })}
|
||||||
|
</SelectItem>
|
||||||
<SelectItem value="single">
|
<SelectItem value="single">
|
||||||
Single MP3s ({counts.single})
|
{t("filterSingle", { count: counts.single })}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="composition">
|
<SelectItem value="composition">
|
||||||
Composition ({counts.composition})
|
{t("filterComposition", { count: counts.composition })}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
// import { useTranslations } from "next-intl";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ export function CatalogItemsTable({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const bulk = useTranslations("pages.admin.catalog.bulk");
|
const bulk = useTranslations("pages.admin.catalog.bulk");
|
||||||
const discovery = useTranslations("pages.admin.catalog.discovery");
|
const discovery = useTranslations("pages.admin.catalog.discovery");
|
||||||
|
const t = useTranslations("pages.admin.catalog.catalogItemsTable.catalogItemsTable");
|
||||||
const [bulkRefreshFrom, setBulkRefreshFrom] = useState<
|
const [bulkRefreshFrom, setBulkRefreshFrom] = useState<
|
||||||
CatalogItemData[] | null
|
CatalogItemData[] | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -818,7 +819,7 @@ export function CatalogItemsTable({
|
|||||||
<div className="flex flex-row items-center justify-between gap-4">
|
<div className="flex flex-row items-center justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
Items ({items.length})
|
{t("itemsCount", { count: items.length })}
|
||||||
{hasDiscoveryFilter &&
|
{hasDiscoveryFilter &&
|
||||||
filteredItems.length !== items.length && (
|
filteredItems.length !== items.length && (
|
||||||
<span className="text-muted-foreground font-normal text-sm ml-2">
|
<span className="text-muted-foreground font-normal text-sm ml-2">
|
||||||
|
|||||||
Reference in new issue
Block a user