feat(hk): expand search and add operational user overview
This commit is contained in:
1 parent
27447ce029
commit
89526af344
44 files changed
+3740
-416
No files matched your search
@@ -81,3 +81,23 @@ Validation: 1,506 tests passed (six skipped), type checking, lint, translation
|
||||
contracts and a production build with fixture configuration. A browser fixture
|
||||
verified the real dialog at 1280 and 390 pixels; server actions were simulated.
|
||||
The new database migration and package publication have not run in production.
|
||||
|
||||
## Housekeeping search and user overview
|
||||
|
||||
The existing global search now includes furniture, normal/Club catalog categories
|
||||
and both ticket sources. Results respect module permissions, accept single-digit
|
||||
IDs, and remain usable when one source fails. Keyboard navigation and cancellation
|
||||
prevent stale search responses from replacing newer results.
|
||||
|
||||
User details open on an operational overview with up to five records from each
|
||||
authorized source: bans, active mute, support tickets, help tickets, reports,
|
||||
payments, catalog purchases and audit activity. Failed sources are distinguished
|
||||
from empty results. User detail and edit pages also apply log permissions before
|
||||
loading activity and exposing counters.
|
||||
|
||||
Italian and Dutch navigation, user management, news and support labels were
|
||||
reviewed. The new search and overview copy has English, Italian and Dutch text;
|
||||
other locales receive English fallback strings. This is a focused editorial pass,
|
||||
not a full translation of every CMS page. No database migration or dependency
|
||||
change is required. Browser checks use real components with simulated data at
|
||||
1280 and 390 pixels; production database behavior still needs deployment validation.
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, UserPlus } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { createUser } from "@/actions/users";
|
||||
@@ -28,17 +29,10 @@ interface CreateUserDialogProps {
|
||||
sessionRank: number;
|
||||
}
|
||||
|
||||
const RANK_OPTIONS: { value: number; label: string }[] = [
|
||||
{ value: 1, label: "User (1)" },
|
||||
{ value: 2, label: "Bronze (2)" },
|
||||
{ value: 3, label: "Silver (3)" },
|
||||
{ value: 4, label: "Gold (4)" },
|
||||
{ value: 5, label: "Moderator (5)" },
|
||||
{ value: 6, label: "Admin (6)" },
|
||||
{ value: 7, label: "Super Admin (7)" },
|
||||
];
|
||||
const RANK_OPTIONS = [1, 2, 3, 4, 5, 6, 7];
|
||||
|
||||
export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
|
||||
const t = useTranslations("pages.admin.users.create");
|
||||
const { run, isPending } = useServerAction();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
@@ -48,7 +42,7 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
|
||||
const [motto, setMotto] = useState("");
|
||||
|
||||
const allowedRanks = RANK_OPTIONS.filter((r) =>
|
||||
sessionRank >= 7 ? true : r.value < sessionRank,
|
||||
sessionRank >= 7 ? true : r < sessionRank,
|
||||
);
|
||||
|
||||
function reset() {
|
||||
@@ -67,22 +61,22 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
|
||||
for (let i = 0; i < buf.length; i++)
|
||||
out += charset[buf[i] % charset.length];
|
||||
setPassword(out);
|
||||
toast.message("Password generated", { description: out });
|
||||
toast.message(t("passwordGenerated"));
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
const u = username.trim();
|
||||
const m = mail.trim();
|
||||
if (!u || !m || !password) {
|
||||
toast.error("Username, email and password are required");
|
||||
toast.error(t("required"));
|
||||
return;
|
||||
}
|
||||
if (u.length < 3 || u.length > 20 || !/^[a-zA-Z0-9_-]+$/.test(u)) {
|
||||
toast.error("Invalid username (3-20 chars, letters/digits/_/-)");
|
||||
toast.error(t("invalidUsername"));
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
toast.error("Password must be at least 6 characters");
|
||||
toast.error(t("passwordLength"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,7 +90,7 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
|
||||
motto: motto.trim(),
|
||||
}),
|
||||
{
|
||||
successMessage: `User "${u}" created.`,
|
||||
successMessage: t("created", { username: u }),
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
reset();
|
||||
@@ -116,31 +110,28 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
New User
|
||||
{t("newUser")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create User</DialogTitle>
|
||||
<DialogDescription>
|
||||
Account is created immediately with default look and 5,000
|
||||
credits/duckets/diamonds.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cu-username">Username</Label>
|
||||
<Label htmlFor="cu-username">{t("username")}</Label>
|
||||
<Input
|
||||
id="cu-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="HabboName"
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
autoComplete="off"
|
||||
maxLength={20}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cu-mail">Email</Label>
|
||||
<Label htmlFor="cu-mail">{t("email")}</Label>
|
||||
<Input
|
||||
id="cu-mail"
|
||||
type="email"
|
||||
@@ -151,14 +142,14 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cu-password">Password</Label>
|
||||
<Label htmlFor="cu-password">{t("password")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="cu-password"
|
||||
type="text"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Minimum 6 characters"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
className="font-mono"
|
||||
/>
|
||||
@@ -167,33 +158,33 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
|
||||
variant="outline"
|
||||
onClick={generatePassword}
|
||||
>
|
||||
Generate
|
||||
{t("generate")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Rank</Label>
|
||||
<Label>{t("rank")}</Label>
|
||||
<Select value={rank} onValueChange={setRank}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allowedRanks.map((r) => (
|
||||
<SelectItem key={r.value} value={String(r.value)}>
|
||||
{r.label}
|
||||
<SelectItem key={r} value={String(r)}>
|
||||
{t("rankOption", { rank: r })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cu-motto">Motto (optional)</Label>
|
||||
<Label htmlFor="cu-motto">{t("motto")}</Label>
|
||||
<Input
|
||||
id="cu-motto"
|
||||
value={motto}
|
||||
onChange={(e) => setMotto(e.target.value)}
|
||||
maxLength={127}
|
||||
placeholder="I'm new here!"
|
||||
placeholder={t("mottoPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -207,7 +198,7 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
|
||||
) : (
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Create User
|
||||
{t("title")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MonitorSmartphone,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "@/components/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -25,40 +26,41 @@ interface UserAccountInfoProps {
|
||||
}
|
||||
|
||||
export function UserAccountInfo({ user }: UserAccountInfoProps) {
|
||||
const t = useTranslations("pages.admin.users.presentation");
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Details</CardTitle>
|
||||
<CardTitle>{t("accountDetails")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">Registered</span>
|
||||
<span className="text-muted-foreground">{t("registered")}</span>
|
||||
<span className="ml-auto font-medium">
|
||||
{formatDate(new Date(user.accountCreated * 1000), "date")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">Last Login</span>
|
||||
<span className="text-muted-foreground">{t("lastLogin")}</span>
|
||||
<span className="ml-auto font-medium">
|
||||
{user.lastLogin
|
||||
? formatDate(new Date(user.lastLogin * 1000))
|
||||
: "Never"}
|
||||
: t("never")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Globe className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">IP Register</span>
|
||||
<span className="text-muted-foreground">{t("ipRegister")}</span>
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<code className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
|
||||
{user.ipRegister}
|
||||
</code>
|
||||
<Link
|
||||
href={`/admin/users?search=${user.ipRegister}`}
|
||||
title="Find other users with this IP"
|
||||
title={t("findIp")}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 text-primary" />
|
||||
</Link>
|
||||
@@ -66,14 +68,14 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Globe className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">IP Current</span>
|
||||
<span className="text-muted-foreground">{t("ipCurrent")}</span>
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<code className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
|
||||
{user.ipCurrent}
|
||||
</code>
|
||||
<Link
|
||||
href={`/admin/users?search=${user.ipCurrent}`}
|
||||
title="Find other users with this IP"
|
||||
title={t("findIp")}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 text-primary" />
|
||||
</Link>
|
||||
@@ -88,22 +90,22 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
|
||||
variant={user.twoFactorConfirmed ? "default" : "outline"}
|
||||
className="ml-auto"
|
||||
>
|
||||
{user.twoFactorConfirmed ? "Enabled" : "Disabled"}
|
||||
{user.twoFactorConfirmed ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<MonitorSmartphone className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">Machine ID</span>
|
||||
<span className="text-muted-foreground">{t("machineId")}</span>
|
||||
<code className="ml-auto text-xs font-mono bg-muted px-2 py-0.5 rounded truncate max-w-[180px]">
|
||||
{user.machine_id || "N/A"}
|
||||
{user.machine_id || t("unavailable")}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Email</span>
|
||||
<span className="text-muted-foreground">{t("email")}</span>
|
||||
<span className="ml-auto font-medium">{user.mail}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Gender</span>
|
||||
<span className="text-muted-foreground">{t("gender")}</span>
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{user.gender}
|
||||
</Badge>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface Badge {
|
||||
id: number;
|
||||
@@ -6,8 +7,9 @@ interface Badge {
|
||||
}
|
||||
|
||||
export function UserBadgesReadonly({ badges }: { badges: Badge[] }) {
|
||||
const t = useTranslations("pages.admin.users.presentation");
|
||||
if (badges.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No badges</p>;
|
||||
return <p className="text-sm text-muted-foreground">{t("noBadges")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
|
||||
import { UserWatchToggle } from "@/components/admin/user-watch-toggle";
|
||||
import Link from "@/components/link";
|
||||
@@ -42,6 +43,7 @@ interface UserHeroProps {
|
||||
activeBan: boolean;
|
||||
mode: "show" | "edit";
|
||||
canEdit: boolean;
|
||||
canViewLogs?: boolean;
|
||||
/** When non-null, render the watch toggle with this initial state. */
|
||||
watching?: boolean | null;
|
||||
}
|
||||
@@ -58,7 +60,9 @@ export function UserHero({
|
||||
mode,
|
||||
canEdit,
|
||||
watching,
|
||||
canViewLogs = true,
|
||||
}: UserHeroProps) {
|
||||
const t = useTranslations("pages.admin.users.presentation");
|
||||
const params = useSearchParams();
|
||||
const list = params.get("list") ?? "";
|
||||
const listHref = userListHref(list);
|
||||
@@ -66,9 +70,9 @@ export function UserHero({
|
||||
<>
|
||||
<AdminBreadcrumb
|
||||
items={[
|
||||
{ label: "Users", href: listHref },
|
||||
{ label: t("users"), href: listHref },
|
||||
{ label: user.username },
|
||||
{ label: mode === "edit" ? "Edit" : "View" },
|
||||
{ label: mode === "edit" ? t("edit") : t("view") },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -107,14 +111,14 @@ export function UserHero({
|
||||
{mode === "show" && canEdit && (
|
||||
<Button variant="outline" size="sm" asChild className="ml-2">
|
||||
<Link href={userDetailHref(user.id, "edit", list)}>
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" /> Edit
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" /> {t("edit")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<Button variant="outline" size="sm" asChild className="ml-2">
|
||||
<Link href={userDetailHref(user.id, "show", list)}>
|
||||
<Eye className="mr-1.5 h-3.5 w-3.5" /> View
|
||||
<Eye className="mr-1.5 h-3.5 w-3.5" /> {t("view")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
@@ -134,22 +138,24 @@ export function UserHero({
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge>{user.permission.rankName || `Rank ${user.rank}`}</Badge>
|
||||
<Badge>
|
||||
{user.permission.rankName || t("rank", { rank: user.rank })}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={isOnline ? "default" : "outline"}
|
||||
className={isOnline ? "bg-[var(--admin-success-subtle)]" : ""}
|
||||
>
|
||||
{isOnline ? (
|
||||
<>
|
||||
<Wifi className="mr-1 h-3 w-3" /> Online
|
||||
<Wifi className="mr-1 h-3 w-3" /> {t("online")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="mr-1 h-3 w-3" /> Offline
|
||||
<WifiOff className="mr-1 h-3 w-3" /> {t("offline")}
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
{activeBan && <Badge variant="destructive">Banned</Badge>}
|
||||
{activeBan && <Badge variant="destructive">{t("banned")}</Badge>}
|
||||
{user.twoFactorConfirmed ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -167,24 +173,32 @@ export function UserHero({
|
||||
<span className="font-semibold">
|
||||
{formatNumber(user.credits)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">credits</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("credits")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<CurrencyIcon kind="diamonds" size={18} />
|
||||
<span className="font-semibold">{formatNumber(diamonds)}</span>
|
||||
<span className="text-muted-foreground text-xs">diamonds</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("diamonds")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<CurrencyIcon kind="duckets" size={18} />
|
||||
<span className="font-semibold">{formatNumber(duckets)}</span>
|
||||
<span className="text-muted-foreground text-xs">duckets</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("duckets")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<CurrencyIcon kind="pixels" size={18} />
|
||||
<span className="font-semibold">
|
||||
{formatNumber(user.pixels)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">pixels</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("pixels")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,18 +211,26 @@ export function UserHero({
|
||||
>
|
||||
<DoorOpen className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||
<p className="text-lg font-bold">{roomCount}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Rooms</p>
|
||||
<p className="text-[10px] text-muted-foreground">{t("rooms")}</p>
|
||||
</Link>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<MessageSquare className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||
<p className="text-lg font-bold">{formatNumber(chatCount)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Messages</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<ArrowLeftRight className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||
<p className="text-lg font-bold">{tradeCount}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Trades</p>
|
||||
</div>
|
||||
{canViewLogs && (
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<MessageSquare className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||
<p className="text-lg font-bold">{formatNumber(chatCount)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("messages")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{canViewLogs && (
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<ArrowLeftRight className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||
<p className="text-lg font-bold">{tradeCount}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("trades")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import type { OverviewSection } from "../_lib/load-user-overview";
|
||||
|
||||
function eventDate(value: string | number | Date | null | undefined) {
|
||||
if (!value) return "—";
|
||||
const numeric =
|
||||
typeof value === "number" ||
|
||||
(typeof value === "string" && /^\d+$/.test(value));
|
||||
const date = numeric ? new Date(Number(value) * 1000) : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "—" : formatDate(date);
|
||||
}
|
||||
|
||||
export async function UserOperationalOverview({
|
||||
sections,
|
||||
}: {
|
||||
sections: OverviewSection[];
|
||||
}) {
|
||||
const t = await getTranslations("pages.admin.users.overview");
|
||||
return (
|
||||
<section className="space-y-4" aria-label={t("title")}>
|
||||
<p className="text-sm text-muted-foreground">{t("hint")}</p>
|
||||
{sections.length === 0 && (
|
||||
<p className="rounded-lg border bg-card p-4 text-sm text-muted-foreground">
|
||||
{t("restricted")}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{sections.map((section) => (
|
||||
<section
|
||||
key={section.key}
|
||||
className="min-w-0 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="font-semibold text-sm">{t(section.key)}</h3>
|
||||
{section.href && (
|
||||
<Link
|
||||
href={section.href}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{t("openModule")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{section.key === "purchases" && (
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{t("purchaseHint")}
|
||||
</p>
|
||||
)}
|
||||
{section.key === "activity" && (
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{t("activityHint")}
|
||||
</p>
|
||||
)}
|
||||
{section.state === "error" ? (
|
||||
<p role="status" className="text-sm text-destructive">
|
||||
{t("error")}
|
||||
</p>
|
||||
) : section.rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{section.rows.map((row) => (
|
||||
<li key={String(row.id)} className="space-y-1 py-2 text-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{row.href ? (
|
||||
<Link
|
||||
href={row.href}
|
||||
className="min-w-0 break-words font-medium text-primary hover:underline"
|
||||
>
|
||||
#{String(row.id)} · {row.title || "—"}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="min-w-0 break-words font-medium">
|
||||
#{String(row.id)} · {row.title || "—"}
|
||||
</span>
|
||||
)}
|
||||
{row.status && (
|
||||
<span className="max-w-28 shrink-0 break-words rounded bg-muted px-1.5 py-0.5 text-xs">
|
||||
{[
|
||||
"active",
|
||||
"expired",
|
||||
"open",
|
||||
"closed",
|
||||
"reported",
|
||||
"reporter",
|
||||
].includes(row.status)
|
||||
? t(`status.${row.status}`)
|
||||
: row.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{section.key === "purchases" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("purchaseDetail", {
|
||||
amount: row.amount ?? "—",
|
||||
credits: row.credits ?? "—",
|
||||
points: row.points ?? "—",
|
||||
type: row.pointsType ?? "—",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{row.detail && (
|
||||
<p className="break-words text-muted-foreground">
|
||||
{row.detail}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{section.key === "mute"
|
||||
? t("muteUntil", { date: eventDate(row.at) })
|
||||
: eventDate(row.at)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { PermissionSet } from "@/lib/permissions";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
allowed: new Set<string>(),
|
||||
execute: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ db: { execute: state.execute } }));
|
||||
vi.mock("@/lib/permissions", async () => ({
|
||||
...(await import("@/lib/permission-slugs")),
|
||||
canAccess: (_: unknown, permission: string) => state.allowed.has(permission),
|
||||
}));
|
||||
|
||||
import { loadUserOverview } from "./load-user-overview";
|
||||
|
||||
const permissions = {} as PermissionSet;
|
||||
const dialect = new MySqlDialect();
|
||||
beforeEach(() => {
|
||||
state.allowed.clear();
|
||||
state.execute
|
||||
.mockReset()
|
||||
.mockResolvedValue([[{ id: 12, title: "Existing record" }], []]);
|
||||
});
|
||||
describe("user operational overview", () => {
|
||||
it("does not query or expose any restricted source", async () => {
|
||||
expect(await loadUserOverview(42, permissions, 4)).toEqual([]);
|
||||
expect(state.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
[PERMS.BANS_VIEW, ["sanctions"]],
|
||||
[PERMS.TICKETS_VIEW, ["tickets", "help"]],
|
||||
[PERMS.MODERATION_VIEW, ["mute", "reports"]],
|
||||
[PERMS.SHOP_VIEW, ["transactions"]],
|
||||
[PERMS.LOGS_VIEW, ["purchases", "activity"]],
|
||||
])("queries only sources allowed by %s", async (permission, keys) => {
|
||||
state.allowed.add(permission);
|
||||
const result = await loadUserOverview(42, permissions, 4);
|
||||
expect(result.map((section) => section.key)).toEqual(keys);
|
||||
expect(state.execute).toHaveBeenCalledTimes(keys.length);
|
||||
for (const [query] of state.execute.mock.calls) {
|
||||
const compiled = dialect.sqlToQuery(query);
|
||||
expect(compiled.sql).toMatch(/LIMIT 5$/);
|
||||
expect(compiled.params).toContain(42);
|
||||
}
|
||||
});
|
||||
it("keeps accessible sources available when another query fails", async () => {
|
||||
state.allowed.add(PERMS.TICKETS_VIEW);
|
||||
state.execute.mockRejectedValueOnce(new Error("database unavailable"));
|
||||
const result = await loadUserOverview(42, permissions, 4);
|
||||
expect(result[0]).toMatchObject({
|
||||
key: "tickets",
|
||||
state: "error",
|
||||
rows: [],
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
key: "help",
|
||||
state: "ready",
|
||||
rows: [{ href: "/admin/help-tickets/12" }],
|
||||
});
|
||||
});
|
||||
it("distinguishes an empty source from a failed source", async () => {
|
||||
state.allowed.add(PERMS.SHOP_VIEW);
|
||||
state.execute.mockResolvedValueOnce([[], []]);
|
||||
expect(await loadUserOverview(42, permissions, 4)).toEqual([
|
||||
{
|
||||
key: "transactions",
|
||||
state: "ready",
|
||||
rows: [],
|
||||
href: "/admin/transactions",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
it("selects and preserves recorded purchase quantities and currency costs", async () => {
|
||||
state.allowed.add(PERMS.LOGS_VIEW);
|
||||
const purchase = {
|
||||
id: 5,
|
||||
title: "Chair",
|
||||
amount: 2,
|
||||
credits: 10,
|
||||
points: 3,
|
||||
pointsType: 5,
|
||||
};
|
||||
state.execute.mockResolvedValueOnce([[purchase], []]);
|
||||
const result = await loadUserOverview(42, permissions, 4);
|
||||
const query = dialect.sqlToQuery(state.execute.mock.calls[0][0]).sql;
|
||||
expect(query).toContain(
|
||||
"amount, cost_credits AS credits, cost_points AS points, points_type AS pointsType",
|
||||
);
|
||||
expect(
|
||||
result.find((section) => section.key === "purchases")?.rows[0],
|
||||
).toMatchObject(purchase);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { type SQL, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { canAccess, PERMS, type PermissionSet } from "@/lib/permissions";
|
||||
|
||||
export type OverviewRow = {
|
||||
id: string | number;
|
||||
title: string;
|
||||
detail?: string | null;
|
||||
status?: string | null;
|
||||
at?: string | number | Date | null;
|
||||
href?: string;
|
||||
amount?: number | null;
|
||||
credits?: number | null;
|
||||
points?: number | null;
|
||||
pointsType?: number | null;
|
||||
};
|
||||
export type OverviewSection = {
|
||||
key: string;
|
||||
href?: string;
|
||||
state: "ready" | "error";
|
||||
rows: OverviewRow[];
|
||||
};
|
||||
|
||||
export async function loadUserOverview(
|
||||
userId: number,
|
||||
permissions: PermissionSet,
|
||||
rank: number,
|
||||
): Promise<OverviewSection[]> {
|
||||
const read = async (query: SQL): Promise<OverviewRow[]> => {
|
||||
const [rows] = await db.execute(query);
|
||||
return rows as unknown as OverviewRow[];
|
||||
};
|
||||
const sources: {
|
||||
key: string;
|
||||
permission: string;
|
||||
href?: string;
|
||||
query: () => Promise<OverviewRow[]>;
|
||||
rowHref?: (id: string | number) => string;
|
||||
}[] = [
|
||||
{
|
||||
key: "sanctions",
|
||||
permission: PERMS.BANS_VIEW,
|
||||
href: "/admin/bans",
|
||||
query: () =>
|
||||
read(
|
||||
sql`SELECT id, type AS title, LEFT(ban_reason, 180) AS detail, timestamp AS at, CASE WHEN ban_expire = 0 OR ban_expire > UNIX_TIMESTAMP() THEN 'active' ELSE 'expired' END AS status FROM bans WHERE user_id = ${userId} ORDER BY timestamp DESC, id DESC LIMIT 5`,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "mute",
|
||||
permission: PERMS.MODERATION_VIEW,
|
||||
query: () =>
|
||||
read(
|
||||
sql`SELECT user_id AS id, '' AS title, mute_end_timestamp AS at FROM users_settings WHERE user_id = ${userId} AND mute_end_timestamp > UNIX_TIMESTAMP() LIMIT 5`,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tickets",
|
||||
permission: PERMS.TICKETS_VIEW,
|
||||
query: () =>
|
||||
read(
|
||||
sql`SELECT id, subject AS title, status, updated_at AS at FROM website_tickets WHERE creator_id = ${userId} ORDER BY updated_at DESC, id DESC LIMIT 5`,
|
||||
),
|
||||
rowHref: (id) => `/admin/tickets/${id}`,
|
||||
},
|
||||
{
|
||||
key: "help",
|
||||
permission: PERMS.TICKETS_VIEW,
|
||||
query: () =>
|
||||
read(
|
||||
sql`SELECT id, title, CASE WHEN open = 1 THEN 'open' ELSE 'closed' END AS status, created_at AS at FROM website_help_center_tickets WHERE user_id = ${userId} ORDER BY created_at DESC, id DESC LIMIT 5`,
|
||||
),
|
||||
rowHref: (id) => `/admin/help-tickets/${id}`,
|
||||
},
|
||||
{
|
||||
key: "reports",
|
||||
permission: PERMS.MODERATION_VIEW,
|
||||
query: () =>
|
||||
read(
|
||||
sql`SELECT id, LEFT(issue, 180) AS title, CASE WHEN reported_id = ${userId} THEN 'reported' ELSE 'reporter' END AS status, timestamp AS at FROM support_tickets WHERE reported_id = ${userId} OR sender_id = ${userId} ORDER BY timestamp DESC, id DESC LIMIT 5`,
|
||||
),
|
||||
rowHref: (id) => `/admin/moderation/cfh/${id}`,
|
||||
},
|
||||
{
|
||||
key: "transactions",
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
href: "/admin/transactions",
|
||||
query: () =>
|
||||
read(
|
||||
sql`SELECT id, COALESCE(description, transaction_id) AS title, CONCAT(amount, ' ', currency) AS detail, status, created_at AS at FROM website_paypal_transactions WHERE user_id = ${userId} ORDER BY created_at DESC, id DESC LIMIT 5`,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "purchases",
|
||||
permission: PERMS.LOGS_VIEW,
|
||||
query: () =>
|
||||
read(
|
||||
sql`SELECT id, catalog_name AS title, amount, cost_credits AS credits, cost_points AS points, points_type AS pointsType, timestamp AS at FROM logs_shop_purchases WHERE user_id = ${userId} ORDER BY timestamp DESC, id DESC LIMIT 5`,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "activity",
|
||||
permission: PERMS.LOGS_VIEW,
|
||||
href: "/admin/logs/audit",
|
||||
query: () =>
|
||||
read(
|
||||
sql`SELECT id, action AS title, created_at AS at FROM admin_audit_log WHERE target = 'User' AND target_id = ${userId} ORDER BY id DESC LIMIT 5`,
|
||||
),
|
||||
},
|
||||
];
|
||||
// Filter before invoking the query; an inaccessible source must never touch its table.
|
||||
return Promise.all(
|
||||
sources
|
||||
.filter((source) => canAccess(permissions, source.permission, rank))
|
||||
.map(async (source) => {
|
||||
try {
|
||||
const rows = await source.query();
|
||||
return {
|
||||
key: source.key,
|
||||
href: source.href,
|
||||
state: "ready" as const,
|
||||
rows: rows.map((row) => ({
|
||||
...row,
|
||||
href: source.rowHref?.(row.id),
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
key: source.key,
|
||||
href: source.href,
|
||||
state: "error" as const,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
sql: [] as string[],
|
||||
tables: [] as string[],
|
||||
failLogs: false,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: () => {
|
||||
throw new Error("not found");
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => {
|
||||
const tables = Object.fromEntries(
|
||||
["User", "Rooms", "RoomTradeLog", "UsersBadges", "UsersCurrency"].map(
|
||||
(name) => [name, { name }],
|
||||
),
|
||||
);
|
||||
return {
|
||||
...tables,
|
||||
db: {
|
||||
select() {
|
||||
let name = "";
|
||||
const rows = () =>
|
||||
name === "User"
|
||||
? [{ id: 42, username: "User", rank: 2, online: "0" }]
|
||||
: [];
|
||||
const query = {
|
||||
from(table: { name: string }) {
|
||||
name = table.name;
|
||||
state.tables.push(name);
|
||||
return query;
|
||||
},
|
||||
where() {
|
||||
return query;
|
||||
},
|
||||
orderBy() {
|
||||
return query;
|
||||
},
|
||||
limit() {
|
||||
return query;
|
||||
},
|
||||
// biome-ignore lint/suspicious/noThenProperty: Drizzle queries are intentionally awaitable.
|
||||
then(resolve: (value: unknown[]) => unknown) {
|
||||
return Promise.resolve(rows()).then(resolve);
|
||||
},
|
||||
catch() {
|
||||
return Promise.resolve(rows());
|
||||
},
|
||||
};
|
||||
return query;
|
||||
},
|
||||
async execute(query: Parameters<MySqlDialect["sqlToQuery"]>[0]) {
|
||||
const compiled = new MySqlDialect().sqlToQuery(query).sql;
|
||||
state.sql.push(compiled);
|
||||
if (state.failLogs && compiled.includes("chatlogs_room"))
|
||||
throw new Error("unavailable");
|
||||
return [[], []];
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { loadUserById } from "./load-user";
|
||||
|
||||
beforeEach(() => {
|
||||
state.sql = [];
|
||||
state.tables = [];
|
||||
state.failLogs = false;
|
||||
});
|
||||
it("skips chat, commands, trade and ban queries when excluded by profile ACL", async () => {
|
||||
const user = await loadUserById("42", { logs: false, bans: false });
|
||||
expect(state.sql.join(" ")).not.toMatch(
|
||||
/chatlogs_room|commandlogs|FROM bans/,
|
||||
);
|
||||
expect(state.tables).not.toContain("RoomTradeLog");
|
||||
expect(user.chatLogs).toEqual([]);
|
||||
expect(user.commandLogs).toEqual([]);
|
||||
expect(user.user.bansReceived).toEqual([]);
|
||||
});
|
||||
it("keeps the user profile available and identifies a failed activity source", async () => {
|
||||
state.failLogs = true;
|
||||
const user = await loadUserById("42", { logs: true, bans: false });
|
||||
expect(user.user.id).toBe(42);
|
||||
expect(user.activityUnavailable).toBe(true);
|
||||
});
|
||||
@@ -14,7 +14,10 @@ async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
|
||||
return rows ?? [];
|
||||
}
|
||||
|
||||
export async function loadUserById(id: string) {
|
||||
export async function loadUserById(
|
||||
id: string,
|
||||
access: { logs: boolean; bans: boolean },
|
||||
) {
|
||||
const numericId = Number(id);
|
||||
const isNumeric =
|
||||
Number.isSafeInteger(numericId) &&
|
||||
@@ -44,6 +47,7 @@ export async function loadUserById(id: string) {
|
||||
.limit(1);
|
||||
if (!user) notFound();
|
||||
|
||||
let activityUnavailable = false;
|
||||
const [
|
||||
currencies,
|
||||
badges,
|
||||
@@ -64,30 +68,40 @@ export async function loadUserById(id: string) {
|
||||
.from(UsersBadges)
|
||||
.where(eq(UsersBadges.userId, user.id))
|
||||
.orderBy(asc(UsersBadges.slotId)),
|
||||
rawRows<{
|
||||
id: number;
|
||||
banReason: string;
|
||||
type: string;
|
||||
timestamp: number;
|
||||
banExpire: number;
|
||||
staffName: string;
|
||||
}>(sql`
|
||||
access.bans
|
||||
? rawRows<{
|
||||
id: number;
|
||||
banReason: string;
|
||||
type: string;
|
||||
timestamp: number;
|
||||
banExpire: number;
|
||||
staffName: string;
|
||||
}>(sql`
|
||||
SELECT b.id, b.ban_reason AS banReason, b.type, b.timestamp, b.ban_expire AS banExpire,
|
||||
COALESCE(s.username, 'Unknown') AS staffName
|
||||
FROM bans b LEFT JOIN users s ON s.id = b.user_staff_id
|
||||
WHERE b.user_id = ${user.id} ORDER BY b.id DESC LIMIT 5`),
|
||||
WHERE b.user_id = ${user.id} ORDER BY b.id DESC LIMIT 5`).catch(
|
||||
() => [],
|
||||
)
|
||||
: [],
|
||||
rawRows<{ rankName: string }>(sql`
|
||||
SELECT COALESCE(rank_name, CONCAT('Rank ', id)) AS rankName FROM permissions WHERE id = ${user.rank} LIMIT 1`),
|
||||
db.select({ total: count() }).from(Rooms).where(eq(Rooms.ownerId, user.id)),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(RoomTradeLog)
|
||||
.where(
|
||||
or(
|
||||
eq(RoomTradeLog.userOneId, user.id),
|
||||
eq(RoomTradeLog.userTwoId, user.id),
|
||||
),
|
||||
),
|
||||
access.logs
|
||||
? db
|
||||
.select({ total: count() })
|
||||
.from(RoomTradeLog)
|
||||
.where(
|
||||
or(
|
||||
eq(RoomTradeLog.userOneId, user.id),
|
||||
eq(RoomTradeLog.userTwoId, user.id),
|
||||
),
|
||||
)
|
||||
.catch(() => {
|
||||
activityUnavailable = true;
|
||||
return [];
|
||||
})
|
||||
: [],
|
||||
db
|
||||
.select({
|
||||
id: Rooms.id,
|
||||
@@ -100,23 +114,37 @@ export async function loadUserById(id: string) {
|
||||
.where(eq(Rooms.ownerId, user.id))
|
||||
.orderBy(desc(Rooms.users))
|
||||
.limit(20),
|
||||
rawRows<{
|
||||
id: number;
|
||||
roomId: number;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
}>(sql`
|
||||
access.logs
|
||||
? rawRows<{
|
||||
id: number;
|
||||
roomId: number;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
}>(sql`
|
||||
SELECT ROW_NUMBER() OVER (ORDER BY timestamp DESC) AS id, room_id AS roomId, timestamp, message
|
||||
FROM chatlogs_room WHERE user_from_id = ${user.id} ORDER BY timestamp DESC LIMIT 20`),
|
||||
rawRows<{
|
||||
id: number;
|
||||
timestamp: number;
|
||||
command: string;
|
||||
params: string;
|
||||
succes: string;
|
||||
}>(sql`
|
||||
FROM chatlogs_room WHERE user_from_id = ${user.id} ORDER BY timestamp DESC LIMIT 20`).catch(
|
||||
() => {
|
||||
activityUnavailable = true;
|
||||
return [];
|
||||
},
|
||||
)
|
||||
: [],
|
||||
access.logs
|
||||
? rawRows<{
|
||||
id: number;
|
||||
timestamp: number;
|
||||
command: string;
|
||||
params: string;
|
||||
succes: string;
|
||||
}>(sql`
|
||||
SELECT ROW_NUMBER() OVER (ORDER BY timestamp DESC) AS id, timestamp, command, params, succes
|
||||
FROM commandlogs WHERE user_id = ${user.id} ORDER BY timestamp DESC LIMIT 10`),
|
||||
FROM commandlogs WHERE user_id = ${user.id} ORDER BY timestamp DESC LIMIT 10`).catch(
|
||||
() => {
|
||||
activityUnavailable = true;
|
||||
return [];
|
||||
},
|
||||
)
|
||||
: [],
|
||||
]);
|
||||
|
||||
const roomCount = Number(roomCountRows[0]?.total ?? 0);
|
||||
@@ -157,6 +185,7 @@ export async function loadUserById(id: string) {
|
||||
userRooms,
|
||||
chatLogs,
|
||||
commandLogs,
|
||||
activityUnavailable,
|
||||
isOnline: user.online === "1",
|
||||
activeBan,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { db } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -25,7 +26,18 @@ export default async function UserEditPage({
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const data = await loadUserById(id);
|
||||
const overviewT = await getTranslations("pages.admin.users.overview");
|
||||
const canViewLogs = canAccess(
|
||||
permissions,
|
||||
PERMS.LOGS_VIEW,
|
||||
session.user.rank,
|
||||
);
|
||||
const canViewBans = canAccess(
|
||||
permissions,
|
||||
PERMS.BANS_VIEW,
|
||||
session.user.rank,
|
||||
);
|
||||
const data = await loadUserById(id, { logs: canViewLogs, bans: canViewBans });
|
||||
const watched = await getWatchedUserIds(session.user.id);
|
||||
|
||||
// Fetch rank list from permissions table for the rank dropdown
|
||||
@@ -45,6 +57,7 @@ export default async function UserEditPage({
|
||||
duckets={data.duckets}
|
||||
roomCount={data.roomCount}
|
||||
chatCount={data.chatCount}
|
||||
canViewLogs={canViewLogs && !data.activityUnavailable}
|
||||
tradeCount={data.tradeCount}
|
||||
isOnline={data.isOnline}
|
||||
activeBan={!!data.activeBan}
|
||||
@@ -57,17 +70,21 @@ export default async function UserEditPage({
|
||||
<Tabs defaultValue="edit" className="space-y-4 min-w-0">
|
||||
<TabsList className="flex-wrap">
|
||||
<TabsTrigger value="edit">Edit Profile</TabsTrigger>
|
||||
<TabsTrigger value="bans">
|
||||
Bans
|
||||
{data.activeBan && (
|
||||
<span className="ml-1.5 flex h-2 w-2 rounded-full bg-destructive" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
{canViewBans && (
|
||||
<TabsTrigger value="bans">
|
||||
Bans
|
||||
{data.activeBan && (
|
||||
<span className="ml-1.5 flex h-2 w-2 rounded-full bg-destructive" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="badges">
|
||||
Badges ({data.user.badges.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rooms">Rooms ({data.roomCount})</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
{canViewLogs && (
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="info">Account Info</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -88,20 +105,22 @@ export default async function UserEditPage({
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bans">
|
||||
<UserBanSection
|
||||
userId={data.user.id}
|
||||
username={data.user.username}
|
||||
bans={data.user.bansReceived.map((b) => ({
|
||||
id: b.id,
|
||||
reason: b.banReason,
|
||||
type: b.type,
|
||||
staffName: b.staff.username,
|
||||
timestamp: b.timestamp,
|
||||
banExpire: b.banExpire,
|
||||
}))}
|
||||
/>
|
||||
</TabsContent>
|
||||
{canViewBans && (
|
||||
<TabsContent value="bans">
|
||||
<UserBanSection
|
||||
userId={data.user.id}
|
||||
username={data.user.username}
|
||||
bans={data.user.bansReceived.map((b) => ({
|
||||
id: b.id,
|
||||
reason: b.banReason,
|
||||
type: b.type,
|
||||
staffName: b.staff.username,
|
||||
timestamp: b.timestamp,
|
||||
banExpire: b.banExpire,
|
||||
}))}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="badges">
|
||||
<UserBadgesSection
|
||||
@@ -117,12 +136,23 @@ export default async function UserEditPage({
|
||||
<UserRooms rooms={data.userRooms} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity">
|
||||
<UserActivityLog
|
||||
chatLogs={data.chatLogs}
|
||||
commandLogs={data.commandLogs}
|
||||
/>
|
||||
</TabsContent>
|
||||
{canViewLogs && (
|
||||
<TabsContent value="activity">
|
||||
{data.activityUnavailable ? (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-lg border bg-card p-4 text-sm text-destructive"
|
||||
>
|
||||
{overviewT("error")}
|
||||
</p>
|
||||
) : (
|
||||
<UserActivityLog
|
||||
chatLogs={data.chatLogs}
|
||||
commandLogs={data.commandLogs}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="info">
|
||||
<UserAccountInfo user={data.user} />
|
||||
|
||||
@@ -6,9 +6,9 @@ import { getWatchedUserIds } from "@/lib/services/watch";
|
||||
import { UserAccountInfo } from "../../_components/user-account-info";
|
||||
import { UserBadgesReadonly } from "../../_components/user-badges-readonly";
|
||||
import { UserHero } from "../../_components/user-hero";
|
||||
import { UserSanctionTimeline } from "../../_components/user-sanction-timeline";
|
||||
import { UserOperationalOverview } from "../../_components/user-operational-overview";
|
||||
import { loadUserById } from "../../_lib/load-user";
|
||||
import { loadUserSanctions } from "../../_lib/load-user-sanctions";
|
||||
import { loadUserOverview } from "../../_lib/load-user-overview";
|
||||
import { UserActivityLog } from "../../[id]/user-activity-log";
|
||||
import { UserQuickActions } from "../../[id]/user-quick-actions";
|
||||
import { UserRooms } from "../../[id]/user-rooms";
|
||||
@@ -25,10 +25,19 @@ export default async function UserShowPage({
|
||||
}
|
||||
|
||||
const t = await getTranslations("pages.admin.users.detail");
|
||||
const overviewT = await getTranslations("pages.admin.users.overview");
|
||||
const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
|
||||
const data = await loadUserById(id);
|
||||
const [sanctions, watched] = await Promise.all([
|
||||
loadUserSanctions(data.user.id),
|
||||
const canViewLogs = canAccess(
|
||||
permissions,
|
||||
PERMS.LOGS_VIEW,
|
||||
session.user.rank,
|
||||
);
|
||||
const data = await loadUserById(id, {
|
||||
logs: canViewLogs,
|
||||
bans: canAccess(permissions, PERMS.BANS_VIEW, session.user.rank),
|
||||
});
|
||||
const [overview, watched] = await Promise.all([
|
||||
loadUserOverview(data.user.id, permissions, session.user.rank),
|
||||
getWatchedUserIds(session.user.id),
|
||||
]);
|
||||
|
||||
@@ -40,6 +49,7 @@ export default async function UserShowPage({
|
||||
duckets={data.duckets}
|
||||
roomCount={data.roomCount}
|
||||
chatCount={data.chatCount}
|
||||
canViewLogs={canViewLogs && !data.activityUnavailable}
|
||||
tradeCount={data.tradeCount}
|
||||
isOnline={data.isOnline}
|
||||
activeBan={!!data.activeBan}
|
||||
@@ -55,8 +65,9 @@ export default async function UserShowPage({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Tabs defaultValue="info" className="space-y-4 min-w-0">
|
||||
<Tabs defaultValue="overview" className="space-y-4 min-w-0">
|
||||
<TabsList className="h-auto flex-wrap justify-start">
|
||||
<TabsTrigger value="overview">{overviewT("title")}</TabsTrigger>
|
||||
<TabsTrigger value="info">{t("tabInfo")}</TabsTrigger>
|
||||
<TabsTrigger value="badges">
|
||||
{t("tabBadges", { count: data.user.badges.length })}
|
||||
@@ -64,12 +75,15 @@ export default async function UserShowPage({
|
||||
<TabsTrigger value="rooms">
|
||||
{t("tabRooms", { count: data.roomCount })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sanctions">
|
||||
{t("tabSanctions", { count: sanctions.events.length })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activity">{t("tabActivity")}</TabsTrigger>
|
||||
{canViewLogs && (
|
||||
<TabsTrigger value="activity">{t("tabActivity")}</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<UserOperationalOverview sections={overview} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="info">
|
||||
<UserAccountInfo user={data.user} />
|
||||
</TabsContent>
|
||||
@@ -87,19 +101,23 @@ export default async function UserShowPage({
|
||||
<UserRooms rooms={data.userRooms} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sanctions">
|
||||
<UserSanctionTimeline
|
||||
events={sanctions.events}
|
||||
muteUntil={sanctions.muteUntil}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity">
|
||||
<UserActivityLog
|
||||
chatLogs={data.chatLogs}
|
||||
commandLogs={data.commandLogs}
|
||||
/>
|
||||
</TabsContent>
|
||||
{canViewLogs && (
|
||||
<TabsContent value="activity">
|
||||
{data.activityUnavailable ? (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-lg border bg-card p-4 text-sm text-destructive"
|
||||
>
|
||||
{overviewT("error")}
|
||||
</p>
|
||||
) : (
|
||||
<UserActivityLog
|
||||
chatLogs={data.chatLogs}
|
||||
commandLogs={data.commandLogs}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{canEdit && (
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
select: vi.fn(),
|
||||
guard: vi.fn(),
|
||||
permissions: new Set<string>(),
|
||||
rateLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => ({
|
||||
...(await import("@/db/schema")),
|
||||
db: { select: mocks.select },
|
||||
}));
|
||||
vi.mock("@/lib/permissions", async () => ({
|
||||
...(await import("@/lib/permission-slugs")),
|
||||
canAccess: (permissions: { has: (slug: string) => boolean }, slug: string) =>
|
||||
permissions.has(slug),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ rateLimit: mocks.rateLimit }));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
withAdmin: (
|
||||
options: unknown,
|
||||
handler: (request: NextRequest, context: unknown) => Promise<Response>,
|
||||
) => {
|
||||
mocks.guard(options);
|
||||
return (request: NextRequest) =>
|
||||
handler(request, {
|
||||
session: { user: { id: 1, rank: 5 } },
|
||||
permissions: {
|
||||
isSuperAdmin: false,
|
||||
has: (slug: string) => mocks.permissions.has(slug),
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
CatalogPages,
|
||||
CatalogPagesBc,
|
||||
Guilds,
|
||||
ItemsBase,
|
||||
User,
|
||||
WebsiteArticles,
|
||||
WebsiteHelpCenterTickets,
|
||||
WebsiteTicket,
|
||||
} from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { GET } from "./route";
|
||||
|
||||
const calls: {
|
||||
table: unknown;
|
||||
fields: Record<string, unknown>;
|
||||
condition: unknown;
|
||||
limit: number;
|
||||
}[] = [];
|
||||
const dialect = new MySqlDialect();
|
||||
const rows = new Map<unknown, unknown[]>();
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.permissions.clear();
|
||||
mocks.rateLimit.mockResolvedValue({ ok: true });
|
||||
mocks.select.mockReset();
|
||||
rows.clear();
|
||||
calls.length = 0;
|
||||
mocks.select.mockImplementation((fields) => {
|
||||
const call = {
|
||||
table: undefined as unknown,
|
||||
fields,
|
||||
condition: undefined as unknown,
|
||||
limit: 0,
|
||||
};
|
||||
const builder = {
|
||||
from(table: unknown) {
|
||||
call.table = table;
|
||||
return builder;
|
||||
},
|
||||
where(condition: unknown) {
|
||||
call.condition = condition;
|
||||
return builder;
|
||||
},
|
||||
orderBy() {
|
||||
return builder;
|
||||
},
|
||||
limit(limit: number) {
|
||||
call.limit = limit;
|
||||
calls.push(call);
|
||||
return Promise.resolve(rows.get(call.table) ?? []);
|
||||
},
|
||||
};
|
||||
return builder;
|
||||
});
|
||||
});
|
||||
|
||||
const search = (q: string) =>
|
||||
GET(
|
||||
new NextRequest(
|
||||
`https://cms.test/api/admin/search?q=${encodeURIComponent(q)}`,
|
||||
),
|
||||
);
|
||||
|
||||
describe("unified admin search", () => {
|
||||
it("requires the staff dashboard gate and performs no entity reads without category permission", async () => {
|
||||
expect(mocks.guard).toHaveBeenCalledWith({
|
||||
permission: PERMS.ADMIN_DASHBOARD,
|
||||
});
|
||||
expect(await (await search("chair")).json()).toEqual({
|
||||
ok: true,
|
||||
results: [],
|
||||
partial: false,
|
||||
});
|
||||
expect(mocks.select).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
[PERMS.USERS_VIEW, [User, Guilds]],
|
||||
[PERMS.NEWS_VIEW, [WebsiteArticles]],
|
||||
[PERMS.CATALOG_VIEW, [ItemsBase, CatalogPages, CatalogPagesBc]],
|
||||
[PERMS.TICKETS_VIEW, [WebsiteTicket, WebsiteHelpCenterTickets]],
|
||||
])("reads only categories granted by %s", async (permission, tables) => {
|
||||
mocks.permissions.add(permission);
|
||||
await search("chair");
|
||||
expect(calls.map((call) => call.table)).toEqual(tables);
|
||||
expect(calls.every((call) => call.limit === 5)).toBe(true);
|
||||
});
|
||||
it("returns exact detail destinations without private user or ticket fields", async () => {
|
||||
[PERMS.USERS_VIEW, PERMS.CATALOG_VIEW, PERMS.TICKETS_VIEW].forEach((p) => {
|
||||
mocks.permissions.add(p);
|
||||
});
|
||||
rows.set(User, [{ id: 12, title: "Alice" }]);
|
||||
rows.set(ItemsBase, [{ id: 12, title: "Chair", subtitle: "chair" }]);
|
||||
rows.set(CatalogPagesBc, [{ id: 12, title: "Chairs" }]);
|
||||
rows.set(WebsiteHelpCenterTickets, [{ id: 12n, title: "Help" }]);
|
||||
const payload = await (await search("12")).json();
|
||||
expect(payload.results.map((r: { url: string }) => r.url)).toEqual([
|
||||
"/admin/users/show/12",
|
||||
"/admin/items/12",
|
||||
"/admin/catalog/builder-club/12",
|
||||
"/admin/help-tickets/12",
|
||||
]);
|
||||
expect(calls.find((call) => call.table === User)?.fields).toEqual({
|
||||
id: User.id,
|
||||
title: User.username,
|
||||
});
|
||||
expect(
|
||||
calls.find((call) => call.table === WebsiteHelpCenterTickets)?.fields,
|
||||
).toEqual({
|
||||
id: WebsiteHelpCenterTickets.id,
|
||||
title: WebsiteHelpCenterTickets.title,
|
||||
});
|
||||
});
|
||||
it("ignores short queries and treats wildcard input literally", async () => {
|
||||
mocks.permissions.add(PERMS.USERS_VIEW);
|
||||
await search(" a ");
|
||||
expect(calls).toHaveLength(0);
|
||||
await search("_%");
|
||||
const query = dialect.sqlToQuery(
|
||||
calls[0].condition as Parameters<MySqlDialect["sqlToQuery"]>[0],
|
||||
);
|
||||
expect(query.params).toEqual(["%\\_\\%%"]);
|
||||
expect(query.sql).not.toContain("mail");
|
||||
});
|
||||
it("bounds oversized queries and rejects partial numeric identifiers", async () => {
|
||||
mocks.permissions.add(PERMS.USERS_VIEW);
|
||||
await search("a".repeat(200));
|
||||
expect(
|
||||
dialect.sqlToQuery(
|
||||
calls[0].condition as Parameters<MySqlDialect["sqlToQuery"]>[0],
|
||||
).params,
|
||||
).toEqual([`%${"a".repeat(64)}%`]);
|
||||
await search("12abc");
|
||||
expect(
|
||||
dialect.sqlToQuery(
|
||||
calls[2].condition as Parameters<MySqlDialect["sqlToQuery"]>[0],
|
||||
).params,
|
||||
).toEqual(["%12abc%"]);
|
||||
});
|
||||
it("supports one-digit identifiers", async () => {
|
||||
mocks.permissions.add(PERMS.USERS_VIEW);
|
||||
await search("1");
|
||||
expect(
|
||||
dialect.sqlToQuery(
|
||||
calls[0].condition as Parameters<MySqlDialect["sqlToQuery"]>[0],
|
||||
).params,
|
||||
).toEqual(["%1%", 1]);
|
||||
});
|
||||
it("reports a partial failure while retaining authorized results", async () => {
|
||||
mocks.permissions.add(PERMS.CATALOG_VIEW);
|
||||
rows.set(ItemsBase, [{ id: 12, title: "Chair", subtitle: "chair" }]);
|
||||
const implementation = mocks.select.getMockImplementation();
|
||||
mocks.select.mockImplementation((fields) => {
|
||||
const builder = implementation?.(fields);
|
||||
const originalFrom = builder.from;
|
||||
builder.from = (table: unknown) => {
|
||||
originalFrom(table);
|
||||
if (table === CatalogPagesBc)
|
||||
builder.limit = () => Promise.reject(new Error("missing table"));
|
||||
return builder;
|
||||
};
|
||||
return builder;
|
||||
});
|
||||
const payload = await (await search("chair")).json();
|
||||
expect(payload.partial).toBe(true);
|
||||
expect(payload.results).toHaveLength(1);
|
||||
expect(payload.results[0].url).toBe("/admin/items/12");
|
||||
});
|
||||
it("caps the combined results while retaining each requested category", async () => {
|
||||
[
|
||||
PERMS.USERS_VIEW,
|
||||
PERMS.NEWS_VIEW,
|
||||
PERMS.CATALOG_VIEW,
|
||||
PERMS.TICKETS_VIEW,
|
||||
].forEach((permission) => {
|
||||
mocks.permissions.add(permission);
|
||||
});
|
||||
[
|
||||
User,
|
||||
WebsiteArticles,
|
||||
ItemsBase,
|
||||
CatalogPages,
|
||||
CatalogPagesBc,
|
||||
WebsiteTicket,
|
||||
WebsiteHelpCenterTickets,
|
||||
].forEach((table) => {
|
||||
rows.set(
|
||||
table,
|
||||
Array.from({ length: 5 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
title: "Match",
|
||||
subtitle: "Name",
|
||||
})),
|
||||
);
|
||||
});
|
||||
const payload = await (await search("Match")).json();
|
||||
expect(payload.results).toHaveLength(20);
|
||||
expect(
|
||||
new Set(payload.results.map((row: { type: string }) => row.type)),
|
||||
).toEqual(
|
||||
new Set(["users", "articles", "furniture", "catalog", "tickets"]),
|
||||
);
|
||||
});
|
||||
it("rate limits without reading records", async () => {
|
||||
mocks.permissions.add(PERMS.USERS_VIEW);
|
||||
mocks.rateLimit.mockResolvedValue({ ok: false, retryAfter: 20 });
|
||||
expect((await search("Alice")).status).toBe(429);
|
||||
expect(mocks.select).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+314
-125
@@ -1,20 +1,24 @@
|
||||
import { eq, like, or } from "drizzle-orm";
|
||||
import { asc, eq, like, or } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { ADMIN_NAV_GROUPS, navItemIsAllowed } from "@/lib/admin-nav";
|
||||
import { apiOk } from "@/lib/api";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
CatalogPages,
|
||||
CatalogPagesBc,
|
||||
db,
|
||||
Guilds,
|
||||
ItemsBase,
|
||||
Rooms,
|
||||
User,
|
||||
WebsiteArticles,
|
||||
WebsiteHelpCenterTickets,
|
||||
WebsiteRareValues,
|
||||
WebsiteShopArticles,
|
||||
WebsiteTicket,
|
||||
} from "@/lib/db";
|
||||
import { canAccess, PERMS } from "@/lib/permissions";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export type AdminSearchResult = {
|
||||
type: string;
|
||||
id: number | string;
|
||||
@@ -22,16 +26,13 @@ export type AdminSearchResult = {
|
||||
subtitle: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const PER_TYPE = 5;
|
||||
const MAX_TOTAL = 20;
|
||||
const MAX_QUERY_LENGTH = 64;
|
||||
|
||||
/** Escape LIKE wildcards so user input can't widen the match. */
|
||||
export function escapeLike(input: string): string {
|
||||
function escapeLike(input: string): string {
|
||||
return input.replace(/[\\%_]/g, (m) => `\\${m}`);
|
||||
}
|
||||
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ADMIN_DASHBOARD },
|
||||
async (request, context) => {
|
||||
@@ -46,14 +47,12 @@ export const GET = withAdmin(
|
||||
{ status: 429, headers: { "retry-after": String(limited.retryAfter) } },
|
||||
);
|
||||
}
|
||||
|
||||
const q = (request.nextUrl.searchParams.get("q") || "")
|
||||
.trim()
|
||||
.slice(0, MAX_QUERY_LENGTH);
|
||||
if (q.length < 2) {
|
||||
if (q.length < 2 && !/^[1-9]$/.test(q)) {
|
||||
return apiOk({ results: [] });
|
||||
}
|
||||
|
||||
const canSearch = (href: string) => {
|
||||
const item = ADMIN_NAV_GROUPS.flatMap((group) => group.items).find(
|
||||
(item) => item.href === href,
|
||||
@@ -67,123 +66,272 @@ export const GET = withAdmin(
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const pattern = `%${escapeLike(q)}%`;
|
||||
const idExact = Number.parseInt(q, 10);
|
||||
const hasId = Number.isFinite(idExact) && String(idExact) === q;
|
||||
|
||||
const [users, articles, rooms, guilds, shopArticles, rareValues] =
|
||||
await Promise.all([
|
||||
canSearch("/admin/users")
|
||||
? db
|
||||
.select({
|
||||
id: User.id,
|
||||
title: User.username,
|
||||
subtitle: User.mail,
|
||||
})
|
||||
.from(User)
|
||||
.where(
|
||||
or(
|
||||
like(User.username, pattern),
|
||||
like(User.mail, pattern),
|
||||
...(hasId ? [eq(User.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE)
|
||||
: Promise.resolve([]),
|
||||
|
||||
canSearch("/admin/articles")
|
||||
? db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
title: WebsiteArticles.title,
|
||||
subtitle: WebsiteArticles.slug,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteArticles.title, pattern),
|
||||
like(WebsiteArticles.slug, pattern),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE)
|
||||
: Promise.resolve([]),
|
||||
|
||||
canSearch("/admin/rooms")
|
||||
? db
|
||||
.select({
|
||||
id: Rooms.id,
|
||||
title: Rooms.name,
|
||||
subtitle: Rooms.ownerName,
|
||||
})
|
||||
.from(Rooms)
|
||||
.where(
|
||||
or(
|
||||
like(Rooms.name, pattern),
|
||||
...(hasId ? [eq(Rooms.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE)
|
||||
: Promise.resolve([]),
|
||||
|
||||
canSearch("/admin/guilds")
|
||||
? db
|
||||
.select({
|
||||
id: Guilds.id,
|
||||
title: Guilds.name,
|
||||
subtitle: Guilds.description,
|
||||
})
|
||||
.from(Guilds)
|
||||
.where(
|
||||
or(
|
||||
like(Guilds.name, pattern),
|
||||
...(hasId ? [eq(Guilds.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE)
|
||||
: Promise.resolve([]),
|
||||
|
||||
canSearch("/admin/shop")
|
||||
? db
|
||||
.select({
|
||||
id: WebsiteShopArticles.id,
|
||||
title: WebsiteShopArticles.name,
|
||||
subtitle: WebsiteShopArticles.info,
|
||||
})
|
||||
.from(WebsiteShopArticles)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteShopArticles.name, pattern),
|
||||
...(hasId
|
||||
? [eq(WebsiteShopArticles.id, BigInt(idExact))]
|
||||
: []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE)
|
||||
: Promise.resolve([]),
|
||||
|
||||
canSearch("/admin/rare-values")
|
||||
? db
|
||||
.select({
|
||||
id: WebsiteRareValues.id,
|
||||
title: WebsiteRareValues.name,
|
||||
subtitle: WebsiteRareValues.itemId,
|
||||
})
|
||||
.from(WebsiteRareValues)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteRareValues.name, pattern),
|
||||
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const hasId =
|
||||
Number.isSafeInteger(idExact) && idExact > 0 && String(idExact) === q;
|
||||
let partial = false;
|
||||
const [
|
||||
users,
|
||||
articles,
|
||||
rooms,
|
||||
guilds,
|
||||
shopArticles,
|
||||
rareValues,
|
||||
furniture,
|
||||
catalog,
|
||||
catalogBc,
|
||||
tickets,
|
||||
helpTickets,
|
||||
] = await Promise.all([
|
||||
canSearch("/admin/users")
|
||||
? db
|
||||
.select({
|
||||
id: User.id,
|
||||
title: User.username,
|
||||
})
|
||||
.from(User)
|
||||
.where(
|
||||
or(
|
||||
like(User.username, pattern),
|
||||
...(hasId ? [eq(User.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(User.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canSearch("/admin/articles")
|
||||
? db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
title: WebsiteArticles.title,
|
||||
subtitle: WebsiteArticles.slug,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteArticles.title, pattern),
|
||||
like(WebsiteArticles.slug, pattern),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(WebsiteArticles.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canSearch("/admin/rooms")
|
||||
? db
|
||||
.select({
|
||||
id: Rooms.id,
|
||||
title: Rooms.name,
|
||||
subtitle: Rooms.ownerName,
|
||||
})
|
||||
.from(Rooms)
|
||||
.where(
|
||||
or(
|
||||
like(Rooms.name, pattern),
|
||||
...(hasId ? [eq(Rooms.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(Rooms.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canSearch("/admin/guilds")
|
||||
? db
|
||||
.select({
|
||||
id: Guilds.id,
|
||||
title: Guilds.name,
|
||||
subtitle: Guilds.description,
|
||||
})
|
||||
.from(Guilds)
|
||||
.where(
|
||||
or(
|
||||
like(Guilds.name, pattern),
|
||||
...(hasId ? [eq(Guilds.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(Guilds.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canSearch("/admin/shop")
|
||||
? db
|
||||
.select({
|
||||
id: WebsiteShopArticles.id,
|
||||
title: WebsiteShopArticles.name,
|
||||
subtitle: WebsiteShopArticles.info,
|
||||
})
|
||||
.from(WebsiteShopArticles)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteShopArticles.name, pattern),
|
||||
...(hasId ? [eq(WebsiteShopArticles.id, BigInt(idExact))] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(WebsiteShopArticles.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canSearch("/admin/rare-values")
|
||||
? db
|
||||
.select({
|
||||
id: WebsiteRareValues.id,
|
||||
title: WebsiteRareValues.name,
|
||||
subtitle: WebsiteRareValues.itemId,
|
||||
})
|
||||
.from(WebsiteRareValues)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteRareValues.name, pattern),
|
||||
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(WebsiteRareValues.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canAccess(
|
||||
context.permissions,
|
||||
PERMS.CATALOG_VIEW,
|
||||
context.session.user.rank,
|
||||
)
|
||||
? db
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
title: ItemsBase.publicName,
|
||||
subtitle: ItemsBase.itemName,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(
|
||||
or(
|
||||
like(ItemsBase.publicName, pattern),
|
||||
like(ItemsBase.itemName, pattern),
|
||||
...(hasId ? [eq(ItemsBase.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(ItemsBase.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canAccess(
|
||||
context.permissions,
|
||||
PERMS.CATALOG_VIEW,
|
||||
context.session.user.rank,
|
||||
)
|
||||
? db
|
||||
.select({ id: CatalogPages.id, title: CatalogPages.caption })
|
||||
.from(CatalogPages)
|
||||
.where(
|
||||
or(
|
||||
like(CatalogPages.caption, pattern),
|
||||
...(hasId ? [eq(CatalogPages.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(CatalogPages.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canAccess(
|
||||
context.permissions,
|
||||
PERMS.CATALOG_VIEW,
|
||||
context.session.user.rank,
|
||||
)
|
||||
? db
|
||||
.select({ id: CatalogPagesBc.id, title: CatalogPagesBc.caption })
|
||||
.from(CatalogPagesBc)
|
||||
.where(
|
||||
or(
|
||||
like(CatalogPagesBc.caption, pattern),
|
||||
...(hasId ? [eq(CatalogPagesBc.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(CatalogPagesBc.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canAccess(
|
||||
context.permissions,
|
||||
PERMS.TICKETS_VIEW,
|
||||
context.session.user.rank,
|
||||
)
|
||||
? db
|
||||
.select({ id: WebsiteTicket.id, title: WebsiteTicket.subject })
|
||||
.from(WebsiteTicket)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteTicket.subject, pattern),
|
||||
...(hasId ? [eq(WebsiteTicket.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(WebsiteTicket.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canAccess(
|
||||
context.permissions,
|
||||
PERMS.TICKETS_VIEW,
|
||||
context.session.user.rank,
|
||||
)
|
||||
? db
|
||||
.select({
|
||||
id: WebsiteHelpCenterTickets.id,
|
||||
title: WebsiteHelpCenterTickets.title,
|
||||
})
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteHelpCenterTickets.title, pattern),
|
||||
...(hasId
|
||||
? [eq(WebsiteHelpCenterTickets.id, BigInt(idExact))]
|
||||
: []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(WebsiteHelpCenterTickets.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const groupMap: Record<
|
||||
string,
|
||||
{ type: string; items: Array<AdminSearchResult> }
|
||||
> = {
|
||||
furniture: { type: "furniture", items: [] },
|
||||
catalog: { type: "catalog", items: [] },
|
||||
tickets: { type: "tickets", items: [] },
|
||||
users: { type: "users", items: [] },
|
||||
articles: { type: "articles", items: [] },
|
||||
rooms: { type: "rooms", items: [] },
|
||||
@@ -191,13 +339,12 @@ export const GET = withAdmin(
|
||||
shop: { type: "shop", items: [] },
|
||||
rareValues: { type: "rareValues", items: [] },
|
||||
};
|
||||
|
||||
for (const r of users) {
|
||||
groupMap.users.items.push({
|
||||
type: "users",
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
subtitle: r.subtitle || "",
|
||||
subtitle: `#${r.id}`,
|
||||
url: `/admin/users/show/${r.id}`,
|
||||
});
|
||||
}
|
||||
@@ -246,10 +393,53 @@ export const GET = withAdmin(
|
||||
url: `/admin/rare-values/${r.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const r of furniture) {
|
||||
groupMap.furniture.items.push({
|
||||
type: "furniture",
|
||||
id: r.id,
|
||||
title: r.title || r.subtitle || `#${r.id}`,
|
||||
subtitle: `${r.subtitle || ""} · #${r.id}`,
|
||||
url: `/admin/items/${r.id}`,
|
||||
});
|
||||
}
|
||||
for (const [rows, kind] of [
|
||||
[catalog, "normal"],
|
||||
[catalogBc, "bc"],
|
||||
] as const) {
|
||||
for (const r of rows)
|
||||
groupMap.catalog.items.push({
|
||||
type: "catalog",
|
||||
id: `${kind}-${r.id}`,
|
||||
title: r.title || `#${r.id}`,
|
||||
subtitle: kind === "bc" ? `BC · #${r.id}` : `#${r.id}`,
|
||||
url:
|
||||
kind === "bc"
|
||||
? `/admin/catalog/builder-club/${r.id}`
|
||||
: `/admin/catalog/${r.id}`,
|
||||
});
|
||||
}
|
||||
for (const r of tickets)
|
||||
groupMap.tickets.items.push({
|
||||
type: "tickets",
|
||||
id: `cms-${r.id}`,
|
||||
title: r.title,
|
||||
subtitle: `#${r.id}`,
|
||||
url: `/admin/tickets/${r.id}`,
|
||||
});
|
||||
for (const r of helpTickets)
|
||||
groupMap.tickets.items.push({
|
||||
type: "tickets",
|
||||
id: `help-${r.id}`,
|
||||
title: r.title,
|
||||
subtitle: `#${r.id}`,
|
||||
url: `/admin/help-tickets/${r.id}`,
|
||||
});
|
||||
const results: AdminSearchResult[] = [];
|
||||
const order = [
|
||||
"users",
|
||||
"furniture",
|
||||
"catalog",
|
||||
"tickets",
|
||||
"articles",
|
||||
"rooms",
|
||||
"guilds",
|
||||
@@ -268,7 +458,6 @@ export const GET = withAdmin(
|
||||
}
|
||||
if (!added) break;
|
||||
}
|
||||
|
||||
return apiOk({ results });
|
||||
return apiOk({ results, partial });
|
||||
},
|
||||
);
|
||||
@@ -18,18 +18,21 @@ import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import {
|
||||
type AdminNavigationOptions,
|
||||
visibleAdminNavigation,
|
||||
} from "@/lib/admin-navigation";
|
||||
import { AdminSearchRequest } from "@/lib/admin-search-request";
|
||||
|
||||
type SearchResult = AdminSearchResult;
|
||||
|
||||
type SearchResponse = {
|
||||
ok: boolean;
|
||||
results: SearchResult[];
|
||||
partial?: boolean;
|
||||
};
|
||||
|
||||
export function SearchDialog({
|
||||
@@ -56,8 +59,9 @@ export function SearchDialog({
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [partial, setPartial] = useState(false);
|
||||
const debouncedQuery = useDebounce(query, 250);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const requestRef = useRef(new AdminSearchRequest());
|
||||
|
||||
const groupLabels: Record<string, string> = {
|
||||
users: t("users"),
|
||||
@@ -66,19 +70,22 @@ export function SearchDialog({
|
||||
guilds: t("guilds"),
|
||||
shop: t("shop"),
|
||||
rareValues: t("rareValues"),
|
||||
furniture: t("furniture"),
|
||||
catalog: t("catalog"),
|
||||
tickets: t("tickets"),
|
||||
};
|
||||
|
||||
const fetchResults = useCallback(async (q: string) => {
|
||||
abortRef.current?.abort();
|
||||
requestRef.current.cancel();
|
||||
setFailed(false);
|
||||
setPartial(false);
|
||||
setResults([]);
|
||||
if (q.length < 2) {
|
||||
if (!canSearchRecords(q)) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const controller = requestRef.current.start();
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
|
||||
@@ -86,28 +93,30 @@ export function SearchDialog({
|
||||
});
|
||||
if (!res.ok) throw new Error("Search request failed");
|
||||
const data: SearchResponse = await res.json();
|
||||
if (abortRef.current !== controller || controller.signal.aborted) return;
|
||||
if (data.ok) setResults(data.results);
|
||||
else setFailed(true);
|
||||
if (!requestRef.current.accepts(controller)) return;
|
||||
if (data.ok) {
|
||||
setResults(data.results);
|
||||
setPartial(Boolean(data.partial));
|
||||
} else setFailed(true);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
if (abortRef.current === controller) {
|
||||
if (requestRef.current.accepts(controller)) {
|
||||
setResults([]);
|
||||
setFailed(true);
|
||||
}
|
||||
} finally {
|
||||
if (abortRef.current === controller) setLoading(false);
|
||||
if (requestRef.current.accepts(controller)) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open || query !== debouncedQuery) return;
|
||||
fetchResults(debouncedQuery);
|
||||
return () => abortRef.current?.abort();
|
||||
}, [debouncedQuery, fetchResults, open]);
|
||||
return () => requestRef.current.cancel();
|
||||
}, [debouncedQuery, fetchResults, open, query]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => abortRef.current?.abort();
|
||||
return () => requestRef.current.cancel();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -121,8 +130,29 @@ export function SearchDialog({
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
function handleQueryChange(value: string) {
|
||||
requestRef.current.cancel();
|
||||
setQuery(value.slice(0, 64));
|
||||
setResults([]);
|
||||
setFailed(false);
|
||||
setPartial(false);
|
||||
setLoading(canSearchRecords(value));
|
||||
}
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) {
|
||||
requestRef.current.cancel();
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
setFailed(false);
|
||||
setPartial(false);
|
||||
setLoading(false);
|
||||
}
|
||||
setOpen(next);
|
||||
}
|
||||
|
||||
function handleSelect(url: string) {
|
||||
setOpen(false);
|
||||
handleOpenChange(false);
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
router.push(url);
|
||||
@@ -132,19 +162,18 @@ export function SearchDialog({
|
||||
const pages = getPages(query.trim().toLocaleLowerCase()).slice(0, 8);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<button
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={t("placeholder")}
|
||||
className="flex items-center gap-2 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-1.5 text-xs text-[var(--admin-text-muted)] hover:border-[var(--admin-accent)]/40 hover:text-[var(--admin-text)] transition-colors cursor-pointer"
|
||||
>
|
||||
<SearchIcon size={14} />
|
||||
<span className="hidden sm:inline">{t("placeholder")}</span>
|
||||
<kbd className="pointer-events-none hidden sm:inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-60">
|
||||
⌘K
|
||||
Ctrl/⌘ K
|
||||
</kbd>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg p-0 gap-0 overflow-hidden">
|
||||
<DialogTitle className="sr-only">{t("placeholder")}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
@@ -157,21 +186,33 @@ export function SearchDialog({
|
||||
<CommandInput
|
||||
placeholder={t("placeholder")}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
onValueChange={handleQueryChange}
|
||||
aria-label={t("placeholder")}
|
||||
maxLength={64}
|
||||
className="h-12"
|
||||
/>
|
||||
<CommandList className="max-h-[400px]">
|
||||
{loading && query.length >= 2 ? (
|
||||
{loading && canSearchRecords(query) ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
…
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !failed && (
|
||||
{!loading && !failed && !partial && canSearchRecords(query) && (
|
||||
<CommandEmpty>{t("noResults")}</CommandEmpty>
|
||||
)}
|
||||
{failed && (
|
||||
{!canSearchRecords(query) && (
|
||||
<p className="p-4 text-sm text-muted-foreground">{t("hint")}</p>
|
||||
)}
|
||||
{(failed || partial) && (
|
||||
<p role="alert" className="p-4 text-sm">
|
||||
{t("loadError")}
|
||||
{t(partial ? "partialError" : "loadError")}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchResults(query)}
|
||||
className="ml-2 underline"
|
||||
>
|
||||
{t("retry")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{pages.length > 0 && (
|
||||
@@ -200,7 +241,9 @@ export function SearchDialog({
|
||||
onSelect={() => handleSelect(item.url)}
|
||||
className="flex flex-col items-start gap-0.5 py-2"
|
||||
>
|
||||
<span className="text-sm font-medium">{item.title}</span>
|
||||
<span className="w-full min-w-0 truncate text-sm font-medium">
|
||||
{item.title}
|
||||
</span>
|
||||
{item.subtitle ? (
|
||||
<span className="text-xs text-muted-foreground truncate w-full">
|
||||
{item.subtitle}
|
||||
@@ -217,6 +260,11 @@ export function SearchDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function canSearchRecords(value: string) {
|
||||
const q = value.trim();
|
||||
return q.length >= 2 || /^[1-9]$/.test(q);
|
||||
}
|
||||
|
||||
function groupResults(results: SearchResult[]) {
|
||||
const grouped: Record<string, SearchResult[]> = {};
|
||||
for (const r of results) {
|
||||
|
||||
@@ -54,6 +54,9 @@ vi.mock("@/lib/db", () => {
|
||||
where() {
|
||||
return query;
|
||||
},
|
||||
orderBy() {
|
||||
return query;
|
||||
},
|
||||
async limit() {
|
||||
state.queried.push(table);
|
||||
return [{ id: 1, title: "Match", subtitle: "Data" }];
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AdminSearchRequest } from "./admin-search-request";
|
||||
|
||||
describe("admin search request ownership", () => {
|
||||
it("rejects a late response after the input changes, even before the next debounce fires", async () => {
|
||||
const requests = new AdminSearchRequest();
|
||||
const first = requests.start();
|
||||
const late = Promise.resolve().then(() => requests.accepts(first));
|
||||
requests.cancel();
|
||||
expect(await late).toBe(false);
|
||||
expect(first.signal.aborted).toBe(true);
|
||||
});
|
||||
it("accepts only the newest request and rejects completion after closing", () => {
|
||||
const requests = new AdminSearchRequest();
|
||||
const first = requests.start();
|
||||
const second = requests.start();
|
||||
expect(requests.accepts(first)).toBe(false);
|
||||
expect(requests.accepts(second)).toBe(true);
|
||||
requests.cancel();
|
||||
expect(requests.accepts(second)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Owns the single request allowed to update a search dialog. */
|
||||
export class AdminSearchRequest {
|
||||
private current: AbortController | null = null;
|
||||
|
||||
cancel() {
|
||||
this.current?.abort();
|
||||
this.current = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.cancel();
|
||||
const controller = new AbortController();
|
||||
this.current = controller;
|
||||
return controller;
|
||||
}
|
||||
|
||||
accepts(controller: AbortController) {
|
||||
return this.current === controller && !controller.signal.aborted;
|
||||
}
|
||||
}
|
||||
@@ -36,16 +36,18 @@ describe("staff smoke contract", () => {
|
||||
expect(detail).toContain("canLiftBan");
|
||||
});
|
||||
|
||||
it("exposes sanction timeline on user show", () => {
|
||||
it("exposes operational overview on user show", () => {
|
||||
expect(
|
||||
existsSync("src/app/admin/users/_components/user-sanction-timeline.tsx"),
|
||||
existsSync(
|
||||
"src/app/admin/users/_components/user-operational-overview.tsx",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(existsSync("src/app/admin/users/_lib/load-user-sanctions.ts")).toBe(
|
||||
expect(existsSync("src/app/admin/users/_lib/load-user-overview.ts")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
readFileSync("src/app/admin/users/show/[id]/page.tsx", "utf8"),
|
||||
).toContain("UserSanctionTimeline");
|
||||
).toContain("UserOperationalOverview");
|
||||
});
|
||||
|
||||
it("applies CMS SQL from drizzle/migrations (not Prisma)", () => {
|
||||
|
||||
@@ -281,6 +281,96 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"users": {
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
|
||||
@@ -1232,6 +1232,84 @@
|
||||
"save": "Запазете промените",
|
||||
"saving": "Запазва се...",
|
||||
"cancel": "Отказ"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3237,6 +3315,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1232,6 +1232,84 @@
|
||||
"save": "Uložte změny",
|
||||
"saving": "Ukládání…",
|
||||
"cancel": "Zrušit"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3237,6 +3315,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1232,6 +1232,84 @@
|
||||
"save": "Save changes",
|
||||
"saving": "Gemmer...",
|
||||
"cancel": "Annuller"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3237,6 +3315,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1203,6 +1203,84 @@
|
||||
"save": "Änderungen speichern",
|
||||
"saving": "Wird gespeichert…",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3208,6 +3286,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
|
||||
@@ -1232,6 +1232,84 @@
|
||||
"save": "Αποθήκευση αλλαγών",
|
||||
"saving": "Αποθήκευση…",
|
||||
"cancel": "Ακύρωση"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3237,6 +3315,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+87
-2
@@ -1566,6 +1566,84 @@
|
||||
"save": "Save changes",
|
||||
"saving": "Saving…",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3815,7 +3893,7 @@
|
||||
},
|
||||
"search": {
|
||||
"pages": "Pages and tools",
|
||||
"description": "Find an admin page or search hotel records.",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"loadError": "Records could not be loaded. You can still open the pages above.",
|
||||
"placeholder": "Search pages, users, rooms…",
|
||||
"users": "Users",
|
||||
@@ -3824,7 +3902,14 @@
|
||||
"guilds": "Groups",
|
||||
"shop": "Shop",
|
||||
"rareValues": "Rare values",
|
||||
"noResults": "No results found."
|
||||
"noResults": "No results found.",
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"table": {
|
||||
"search": "Search…",
|
||||
|
||||
@@ -1203,6 +1203,84 @@
|
||||
"save": "Guardar cambios",
|
||||
"saving": "Guardando…",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3208,6 +3286,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
|
||||
@@ -281,6 +281,96 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"users": {
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
|
||||
@@ -1203,6 +1203,84 @@
|
||||
"save": "Enregistrer les modifications",
|
||||
"saving": "Enregistrement…",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3208,6 +3286,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
|
||||
@@ -1232,6 +1232,84 @@
|
||||
"save": "Spremite promjene",
|
||||
"saving": "Spremanje...",
|
||||
"cancel": "Odustani"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3237,6 +3315,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1232,6 +1232,84 @@
|
||||
"save": "Módosítások mentése",
|
||||
"saving": "Mentés…",
|
||||
"cancel": "Mégse"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3237,6 +3315,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+129
-44
@@ -21,8 +21,8 @@
|
||||
"friends": "Amici",
|
||||
"messages": "Messaggi",
|
||||
"admin": "Admin",
|
||||
"openMenu": "Open menu",
|
||||
"closeMenu": "Close menu",
|
||||
"openMenu": "Apri menu",
|
||||
"closeMenu": "Chiudi menu",
|
||||
"drawBadge": "Crea distintivo",
|
||||
"mod": "Mod"
|
||||
},
|
||||
@@ -991,7 +991,7 @@
|
||||
"economy": "Economia",
|
||||
"catalog": "Catalogo",
|
||||
"shop": "Negozio",
|
||||
"marketplace": "Marketplace",
|
||||
"marketplace": "Mercatino",
|
||||
"transactions": "Transazioni",
|
||||
"vouchers": "Buoni",
|
||||
"subscriptions": "Abbonamenti",
|
||||
@@ -1042,12 +1042,12 @@
|
||||
"hubNewsMedia": "Notizie e media",
|
||||
"hubSiteContent": "Contenuti sito",
|
||||
"hubEngagement": "Community",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability",
|
||||
"hubUsers": "Utenti",
|
||||
"hubAccess": "Accesso e sicurezza",
|
||||
"hubCatalog": "Catalogo e rari",
|
||||
"hubShop": "Negozio e pagamenti",
|
||||
"hubTools": "Strumenti",
|
||||
"hubObservability": "Monitoraggio e diagnostica",
|
||||
"community": "Community",
|
||||
"monitoring": "Monitoraggio",
|
||||
"staffAccess": "Gradi e permessi",
|
||||
@@ -1057,7 +1057,7 @@
|
||||
"tradeLog": "Log trade",
|
||||
"tools": "Strumenti",
|
||||
"guilds": "Gruppi",
|
||||
"itemsBase": "Items base",
|
||||
"itemsBase": "Definizioni furni",
|
||||
"studio": "Studio",
|
||||
"peopleSupport": "Utenti e assistenza",
|
||||
"catalogFurniture": "Catalogo e furni",
|
||||
@@ -1519,11 +1519,11 @@
|
||||
"resetPassword": "Reimposta password",
|
||||
"deleteUser": "Elimina utente",
|
||||
"confirmDelete": "Sei sicuro di voler eliminare {username}? Questa azione è irreversibile.",
|
||||
"tabInfo": "Account Info",
|
||||
"tabBadges": "Badges ({count})",
|
||||
"tabRooms": "Rooms ({count})",
|
||||
"tabSanctions": "Sanctions ({count})",
|
||||
"tabActivity": "Activity"
|
||||
"tabInfo": "Informazioni account",
|
||||
"tabBadges": "Distintivi ({count})",
|
||||
"tabRooms": "Stanze ({count})",
|
||||
"tabSanctions": "Sanzioni ({count})",
|
||||
"tabActivity": "Attività"
|
||||
},
|
||||
"editForm": {
|
||||
"title": "Modifica utente: {username}",
|
||||
@@ -1541,6 +1541,84 @@
|
||||
"save": "Salva modifiche",
|
||||
"saving": "Salvataggio…",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Utenti",
|
||||
"edit": "Modifica",
|
||||
"view": "Visualizza",
|
||||
"rank": "Grado {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Bannato",
|
||||
"credits": "crediti",
|
||||
"diamonds": "diamanti",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixel",
|
||||
"rooms": "Stanze",
|
||||
"messages": "Messaggi",
|
||||
"trades": "Scambi",
|
||||
"accountDetails": "Dettagli account",
|
||||
"registered": "Registrazione",
|
||||
"lastLogin": "Ultimo accesso",
|
||||
"ipRegister": "IP di registrazione",
|
||||
"ipCurrent": "IP attuale",
|
||||
"machineId": "ID dispositivo",
|
||||
"email": "Email",
|
||||
"gender": "Genere",
|
||||
"never": "Mai",
|
||||
"findIp": "Cerca utenti con questo IP",
|
||||
"enabled": "Attiva",
|
||||
"disabled": "Disattiva",
|
||||
"unavailable": "Non disponibile",
|
||||
"noBadges": "Nessun distintivo assegnato"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "Nuovo utente",
|
||||
"title": "Crea utente",
|
||||
"description": "Crea subito l’account con un avatar predefinito e 5.000 crediti, pixel, duckets e diamanti.",
|
||||
"username": "Nome utente",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Grado",
|
||||
"rankOption": "Grado {rank}",
|
||||
"motto": "Motto (facoltativo)",
|
||||
"usernamePlaceholder": "NuovoHabbo",
|
||||
"passwordPlaceholder": "Almeno 6 caratteri",
|
||||
"mottoPlaceholder": "Sono nuovo qui!",
|
||||
"generate": "Genera",
|
||||
"passwordGenerated": "Password generata nel campo password.",
|
||||
"required": "Nome utente, email e password sono obbligatori.",
|
||||
"invalidUsername": "Usa da 3 a 20 caratteri: lettere, numeri, _ o -.",
|
||||
"passwordLength": "La password deve contenere almeno 6 caratteri.",
|
||||
"created": "Utente {username} creato."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Riepilogo",
|
||||
"hint": "Fino a 5 record recenti per fonte. Sono mostrate solo le sezioni a cui hai accesso.",
|
||||
"restricted": "Non hai i permessi per visualizzare i dati operativi.",
|
||||
"sanctions": "Ban recenti",
|
||||
"mute": "Mute attivo",
|
||||
"tickets": "Ticket di assistenza",
|
||||
"help": "Richieste al centro assistenza",
|
||||
"reports": "Segnalazioni in gioco",
|
||||
"transactions": "Pagamenti",
|
||||
"purchases": "Acquisti dal catalogo",
|
||||
"activity": "Azioni dello staff registrate",
|
||||
"openModule": "Apri gestione",
|
||||
"purchaseHint": "Quantità e costi nelle valute registrate.",
|
||||
"purchaseDetail": "Quantità: {amount} · Crediti: {credits} · Punti: {points} (tipo {type})",
|
||||
"activityHint": "Voci del registro staff relative a questo account. Non è la cronologia completa delle attività del giocatore.",
|
||||
"muteUntil": "Mute fino al {date}",
|
||||
"error": "Impossibile caricare questa fonte. Ricarica la pagina per riprovare.",
|
||||
"empty": "Nessun record trovato.",
|
||||
"status": {
|
||||
"active": "Attivo",
|
||||
"expired": "Scaduto",
|
||||
"open": "Aperto",
|
||||
"closed": "Chiuso",
|
||||
"reported": "Utente segnalato",
|
||||
"reporter": "Segnalante"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -1686,19 +1764,19 @@
|
||||
"actionsTitle": "Azioni",
|
||||
"requesterTitle": "Richiedente",
|
||||
"detailsTitle": "Dettagli",
|
||||
"rankLabel": "Rank {rank}",
|
||||
"rankLabel": "Grado {rank}",
|
||||
"messageCountLabel": "Messaggi",
|
||||
"success": {
|
||||
"replied": "Risposta inviata.",
|
||||
"closed": "Ticket chiuso.",
|
||||
"reopened": "Ticket riaperto.",
|
||||
"banLifted": "Ban lifted and ticket closed."
|
||||
"banLifted": "Ban revocato e ticket chiuso."
|
||||
},
|
||||
"queueHint": "Questa coda e il help center pubblico. I ticket del supporto CMS stanno nella tab Supporto CMS.",
|
||||
"queueOtherCta": "Apri supporto CMS",
|
||||
"queueCounts": "Aperti qui: {here} · Supporto CMS: {other}",
|
||||
"liftBanSubmit": "Lift requester ban & close",
|
||||
"requesterBanned": "Requester banned"
|
||||
"liftBanSubmit": "Revoca il ban del richiedente e chiudi",
|
||||
"requesterBanned": "Richiedente bannato"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -3383,11 +3461,11 @@
|
||||
"urlCopied": "URL copiato negli appunti"
|
||||
},
|
||||
"hubs": {
|
||||
"newsMedia": "News & media",
|
||||
"newsMedia": "Notizie e contenuti multimediali",
|
||||
"newsMediaSubtitle": "Articoli, foto, banner, ads e media library",
|
||||
"siteContent": "Contenuti sito",
|
||||
"siteContentSubtitle": "Navigazione, help, box, tag e prefissi",
|
||||
"engagement": "Engagement",
|
||||
"engagement": "Attività della community",
|
||||
"engagementSubtitle": "Eventi, sondaggi e ticket di supporto",
|
||||
"users": "Utenti",
|
||||
"usersSubtitle": "Directory, online, candidature e team",
|
||||
@@ -3411,10 +3489,10 @@
|
||||
"articles": "Articoli",
|
||||
"photos": "Foto",
|
||||
"banners": "Banner",
|
||||
"ads": "Ads",
|
||||
"ads": "Pubblicità",
|
||||
"media": "Media",
|
||||
"navigator": "Navigator",
|
||||
"help": "Help",
|
||||
"navigator": "Navigatore",
|
||||
"help": "Assistenza",
|
||||
"boxes": "Box",
|
||||
"tags": "Tag",
|
||||
"prefixes": "Prefissi",
|
||||
@@ -3422,9 +3500,9 @@
|
||||
"eventTypes": "Tipi evento",
|
||||
"polls": "Sondaggi",
|
||||
"tickets": "Supporto CMS",
|
||||
"helpTickets": "Help center",
|
||||
"helpTickets": "Centro assistenza",
|
||||
"templates": "Template",
|
||||
"directory": "Directory",
|
||||
"directory": "Elenco utenti",
|
||||
"multiAccounts": "Multi-account",
|
||||
"online": "Online",
|
||||
"applications": "Candidature",
|
||||
@@ -3433,26 +3511,26 @@
|
||||
"ip": "IP",
|
||||
"vpn": "VPN",
|
||||
"permissions": "Permessi",
|
||||
"wordFilter": "Word filter",
|
||||
"wordFilter": "Filtro parole",
|
||||
"overview": "Panoramica",
|
||||
"actions": "Azioni",
|
||||
"cfh": "CFH",
|
||||
"cfh": "Richieste di moderazione",
|
||||
"team": "Team",
|
||||
"catalogTab": "Catalogo",
|
||||
"rares": "Rare",
|
||||
"badges": "Badge",
|
||||
"achievements": "Achievement",
|
||||
"sounds": "Suoni",
|
||||
"shopTab": "Shop",
|
||||
"shopTab": "Negozio",
|
||||
"transactions": "Transazioni",
|
||||
"vouchers": "Voucher",
|
||||
"subscriptions": "Abbonamenti",
|
||||
"calendar": "Calendario",
|
||||
"history": "Cronologia",
|
||||
"moderationTab": "Moderazione",
|
||||
"monitoring": "Monitoring",
|
||||
"monitoring": "Monitoraggio",
|
||||
"settingsTab": "Impostazioni",
|
||||
"embed": "Embed",
|
||||
"embed": "Incorporamento",
|
||||
"apiKeys": "API key",
|
||||
"points": "Punti",
|
||||
"ranks": "Rank",
|
||||
@@ -3461,37 +3539,37 @@
|
||||
"theme": "Tema",
|
||||
"maintenance": "Manutenzione",
|
||||
"favicon": "Favicon",
|
||||
"emulator": "Emulator",
|
||||
"emulator": "Emulatore",
|
||||
"email": "Email",
|
||||
"housekeeping": "Archivio permessi",
|
||||
"commando": "Commando",
|
||||
"commando": "Centro operativo",
|
||||
"rooms": "Stanze",
|
||||
"import": "Import",
|
||||
"import": "Importazione",
|
||||
"importOverview": "Panoramica",
|
||||
"importBadges": "Badge",
|
||||
"importFurni": "Furni",
|
||||
"importUpload": "Upload",
|
||||
"importUpload": "Caricamento",
|
||||
"importClothing": "Abbigliamento",
|
||||
"importEffects": "Effetti",
|
||||
"importPets": "Pet",
|
||||
"importClone": "Clone",
|
||||
"importSync": "Sync",
|
||||
"importClone": "Clonazione",
|
||||
"importSync": "Sincronizzazione",
|
||||
"importRepairIcons": "Ripara icone",
|
||||
"importAudit": "Audit",
|
||||
"importAudit": "Verifica dati",
|
||||
"translations": "Traduzioni",
|
||||
"logs": "Log",
|
||||
"audit": "Audit",
|
||||
"audit": "Registro modifiche",
|
||||
"chat": "Chat",
|
||||
"commands": "Comandi",
|
||||
"trades": "Trade",
|
||||
"analytics": "Analytics",
|
||||
"analytics": "Statistiche",
|
||||
"activity": "Attività",
|
||||
"economy": "Economia",
|
||||
"devops": "DevOps",
|
||||
"errors": "Errori",
|
||||
"alerts": "Alert",
|
||||
"toolsGroup": "Strumenti",
|
||||
"ticketInbox": "Inbox",
|
||||
"ticketInbox": "Ticket ricevuti",
|
||||
"cmsErrors": "Errori CMS",
|
||||
"themeBuilder": "Editor del tema",
|
||||
"studio": "Studio"
|
||||
@@ -3506,7 +3584,7 @@
|
||||
"analyticsSubtitle": "Attività hotel, retention ed economia",
|
||||
"devops": "DevOps",
|
||||
"devopsSubtitle": "Salute servizi ed errori emulator",
|
||||
"import": "Import",
|
||||
"import": "Importazione",
|
||||
"importSubtitle": "Furni, badge, abbigliamento e strumenti asset"
|
||||
},
|
||||
"settings": {
|
||||
@@ -3790,7 +3868,7 @@
|
||||
},
|
||||
"search": {
|
||||
"pages": "Pagine e strumenti",
|
||||
"description": "Trova una pagina di gestione o cerca nei dati dell’hotel.",
|
||||
"description": "Cerca pagine, utenti, furni, categorie del catalogo, articoli e ticket di assistenza.",
|
||||
"loadError": "Impossibile caricare i risultati. Puoi comunque aprire le pagine elencate.",
|
||||
"placeholder": "Cerca pagine, utenti, stanze…",
|
||||
"users": "Utenti",
|
||||
@@ -3799,7 +3877,14 @@
|
||||
"guilds": "Gruppi",
|
||||
"shop": "Negozio",
|
||||
"rareValues": "Valori dei rari",
|
||||
"noResults": "Nessun risultato trovato."
|
||||
"noResults": "Nessun risultato trovato.",
|
||||
"furniture": "Furni",
|
||||
"catalog": "Categorie del catalogo",
|
||||
"tickets": "Ticket di assistenza",
|
||||
"loading": "Ricerca in corso…",
|
||||
"hint": "Inserisci un ID numerico o almeno 2 caratteri oppure scegli una pagina.",
|
||||
"retry": "Riprova",
|
||||
"partialError": "Alcuni risultati non sono disponibili. Riprova."
|
||||
},
|
||||
"table": {
|
||||
"search": "Cerca…",
|
||||
|
||||
@@ -281,6 +281,96 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"users": {
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
|
||||
+185
-60
@@ -5,7 +5,7 @@
|
||||
"articles": "Artikelen",
|
||||
"photos": "Foto's",
|
||||
"staff": "Team",
|
||||
"rankings": "Rankings",
|
||||
"rankings": "Ranglijsten",
|
||||
"guilds": "Gildes",
|
||||
"events": "Evenementen",
|
||||
"polls": "Peilingen",
|
||||
@@ -20,7 +20,7 @@
|
||||
"radio": "Radio",
|
||||
"friends": "Vrienden",
|
||||
"messages": "Berichten",
|
||||
"admin": "Admin",
|
||||
"admin": "Beheer",
|
||||
"openMenu": "Menu openen",
|
||||
"closeMenu": "Menu sluiten",
|
||||
"drawBadge": "Badge tekenen",
|
||||
@@ -987,26 +987,26 @@
|
||||
"photos": "Foto's",
|
||||
"tickets": "Supportbalie",
|
||||
"helpTickets": "Helpcentrum-tickets",
|
||||
"events": "Events",
|
||||
"events": "Evenementen",
|
||||
"polls": "Polls",
|
||||
"guilds": "Gildes",
|
||||
"banners": "Banners",
|
||||
"prefixes": "Prefixes",
|
||||
"prefixes": "Voorvoegsels",
|
||||
"usersAndAccess": "Gebruikers & toegang",
|
||||
"users": "Gebruikers",
|
||||
"bans": "Bans",
|
||||
"bans": "Verbanningen",
|
||||
"ipManagement": "IP-beheer",
|
||||
"vpn": "VPN",
|
||||
"applications": "Sollicitaties",
|
||||
"teams": "Teams",
|
||||
"permissions": "Permissies",
|
||||
"onlineUsers": "Online users",
|
||||
"moderation": "Moderation",
|
||||
"onlineUsers": "Online gebruikers",
|
||||
"moderation": "Moderatie",
|
||||
"economy": "Economie",
|
||||
"catalog": "Catalogus",
|
||||
"catalogMaintenance": "Catalogus-onderhoud",
|
||||
"shop": "Winkel",
|
||||
"marketplace": "Marketplace",
|
||||
"marketplace": "Marktplaats",
|
||||
"transactions": "Transacties",
|
||||
"vouchers": "Vouchers",
|
||||
"subscriptions": "Abonnementen",
|
||||
@@ -1017,11 +1017,11 @@
|
||||
"radio": "Radio",
|
||||
"radioHistory": "Radio geschiedenis",
|
||||
"radioBanners": "Banners",
|
||||
"radioEmbed": "Embed",
|
||||
"radioEmbed": "Insluiten",
|
||||
"radioApiKeys": "API-sleutels",
|
||||
"radioModeration": "Moderatie",
|
||||
"radioSettings": "Instellingen",
|
||||
"radioMonitoring": "Monitoring",
|
||||
"radioMonitoring": "Bewaking",
|
||||
"radioPoints": "Punten",
|
||||
"radioRanks": "Rangen",
|
||||
"radioAutodj": "Auto DJ",
|
||||
@@ -1030,8 +1030,8 @@
|
||||
"emulator": "Emulator",
|
||||
"alerts": "Meldingen",
|
||||
"logs": "Logboeken",
|
||||
"auditLog": "Audit log",
|
||||
"analytics": "Analytics",
|
||||
"auditLog": "Wijzigingslogboek",
|
||||
"analytics": "Statistieken",
|
||||
"devops": "DevOps",
|
||||
"wordFilter": "Woordfilter",
|
||||
"maintenance": "Onderhoud",
|
||||
@@ -1053,26 +1053,44 @@
|
||||
"housekeeping": "Permissie-archief",
|
||||
"hotelManagementPanel": "Hotelbeheerpaneel",
|
||||
"rankLabel": "Rang {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability",
|
||||
"hubNewsMedia": "Nieuws en media",
|
||||
"hubSiteContent": "Website-inhoud",
|
||||
"hubEngagement": "Communityactiviteiten",
|
||||
"hubUsers": "Gebruikers",
|
||||
"hubAccess": "Toegang en beveiliging",
|
||||
"hubCatalog": "Catalogus en zeldzame meubels",
|
||||
"hubShop": "Winkel en betalingen",
|
||||
"hubTools": "Hulpmiddelen",
|
||||
"hubObservability": "Bewaking en diagnostiek",
|
||||
"community": "Community",
|
||||
"monitoring": "Monitoring",
|
||||
"monitoring": "Bewaking",
|
||||
"staffAccess": "Rangen & rechten",
|
||||
"multiAccounts": "Multi-accounts",
|
||||
"multiAccounts": "Gekoppelde accounts",
|
||||
"chatLog": "Chatlog",
|
||||
"commandLog": "Commandolog",
|
||||
"tradeLog": "Tradelog",
|
||||
"tools": "Tools",
|
||||
"itemsBase": "Items base",
|
||||
"tools": "Hulpmiddelen",
|
||||
"itemsBase": "Meubeldefinities",
|
||||
"adminMenu": "Adminmenu",
|
||||
"studio": "Studio"
|
||||
"studio": "Studio",
|
||||
"peopleSupport": "Gebruikers en ondersteuning",
|
||||
"catalogFurniture": "Catalogus en meubels",
|
||||
"communityContent": "Community en inhoud",
|
||||
"staffPermissions": "Medewerkers en toegang",
|
||||
"cmsErrors": "CMS-fouten",
|
||||
"themeBuilder": "Thema-editor",
|
||||
"openSidebar": "Menu openen",
|
||||
"closeSidebar": "Menu sluiten",
|
||||
"search": {
|
||||
"placeholder": "Zoeken…",
|
||||
"noResults": "Geen resultaten gevonden",
|
||||
"users": "Gebruikers",
|
||||
"articles": "Artikelen",
|
||||
"rooms": "Kamers",
|
||||
"guilds": "Groepen",
|
||||
"shop": "Winkel",
|
||||
"hint": "Typ om te zoeken naar gebruikers, artikelen, kamers, groepen en winkelartikelen."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1153,7 +1171,12 @@
|
||||
"restoreRevision": "Herstellen in editor",
|
||||
"confirmRevision": "De huidige inhoud vervangen door deze vorige versie als concept? Controleer de inhoud en kies een publicatiestatus voordat je opslaat.",
|
||||
"editConflict": "Dit artikel is gewijzigd sinds je het hebt geopend. Je wijzigingen zijn niet toegepast. Kopieer je werk, herlaad het artikel en voeg de wijzigingen samen voordat je opnieuw opslaat.",
|
||||
"retryRecovery": "Herstelgegevens opnieuw laden zonder dit formulier te wijzigen"
|
||||
"retryRecovery": "Herstelgegevens opnieuw laden zonder dit formulier te wijzigen",
|
||||
"statusDraft": "Concept",
|
||||
"statusScheduled": "Ingepland",
|
||||
"statusPublished": "Gepubliceerd",
|
||||
"publishAt": "Publiceren op",
|
||||
"publishedAt": "Gepubliceerd op"
|
||||
},
|
||||
"listActionError": "De actie voor dit artikel kon niet worden voltooid.",
|
||||
"listStatus": "Publicatiestatus",
|
||||
@@ -1172,7 +1195,11 @@
|
||||
"listPagination": "Artikelpagina’s",
|
||||
"listPrevious": "Vorige",
|
||||
"listNext": "Volgende",
|
||||
"listPage": "Pagina {page} van {total}"
|
||||
"listPage": "Pagina {page} van {total}",
|
||||
"articlesShown": "Getoonde artikelen",
|
||||
"latest": "Laatste artikel",
|
||||
"mostRecentPost": "Meest recente publicatie",
|
||||
"recentArticles": "Recente artikelen"
|
||||
},
|
||||
"events": {
|
||||
"newEvent": "+ Nieuw evenement",
|
||||
@@ -1472,7 +1499,7 @@
|
||||
"registered": "Geregistreerd",
|
||||
"lastOnline": "Laatst online",
|
||||
"ipAddress": "IP-adres",
|
||||
"machineId": "Machine ID",
|
||||
"machineId": "Apparaat-ID",
|
||||
"sendAlert": "Melding sturen",
|
||||
"sendMessage": "Bericht sturen",
|
||||
"disconnectUser": "Verbinding verbreken",
|
||||
@@ -1480,11 +1507,11 @@
|
||||
"resetPassword": "Wachtwoord resetten",
|
||||
"deleteUser": "Gebruiker verwijderen",
|
||||
"confirmDelete": "Weet je zeker dat je {username} wilt verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||
"tabInfo": "Account Info",
|
||||
"tabInfo": "Accountgegevens",
|
||||
"tabBadges": "Badges ({count})",
|
||||
"tabRooms": "Rooms ({count})",
|
||||
"tabSanctions": "Sanctions ({count})",
|
||||
"tabActivity": "Activity"
|
||||
"tabRooms": "Kamers ({count})",
|
||||
"tabSanctions": "Sancties ({count})",
|
||||
"tabActivity": "Activiteit"
|
||||
},
|
||||
"editForm": {
|
||||
"title": "Gebruiker bewerken: {username}",
|
||||
@@ -1502,6 +1529,84 @@
|
||||
"save": "Wijzigingen opslaan",
|
||||
"saving": "Opslaan…",
|
||||
"cancel": "Annuleren"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Gebruikers",
|
||||
"edit": "Bewerken",
|
||||
"view": "Bekijken",
|
||||
"rank": "Rang {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Verbannen",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamanten",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Kamers",
|
||||
"messages": "Berichten",
|
||||
"trades": "Ruiltransacties",
|
||||
"accountDetails": "Accountgegevens",
|
||||
"registered": "Geregistreerd",
|
||||
"lastLogin": "Laatste login",
|
||||
"ipRegister": "Registratie-IP",
|
||||
"ipCurrent": "Huidig IP",
|
||||
"machineId": "Apparaat-ID",
|
||||
"email": "E-mail",
|
||||
"gender": "Geslacht",
|
||||
"never": "Nooit",
|
||||
"findIp": "Gebruikers met dit IP zoeken",
|
||||
"enabled": "Ingeschakeld",
|
||||
"disabled": "Uitgeschakeld",
|
||||
"unavailable": "Niet beschikbaar",
|
||||
"noBadges": "Geen badges toegewezen"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "Nieuwe gebruiker",
|
||||
"title": "Gebruiker aanmaken",
|
||||
"description": "Maakt het account direct aan met een standaardavatar en 5.000 credits, pixels, duckets en diamanten.",
|
||||
"username": "Gebruikersnaam",
|
||||
"email": "E-mail",
|
||||
"password": "Wachtwoord",
|
||||
"rank": "Rang",
|
||||
"rankOption": "Rang {rank}",
|
||||
"motto": "Motto (optioneel)",
|
||||
"usernamePlaceholder": "NieuweHabbo",
|
||||
"passwordPlaceholder": "Minimaal 6 tekens",
|
||||
"mottoPlaceholder": "Ik ben hier nieuw!",
|
||||
"generate": "Genereren",
|
||||
"passwordGenerated": "Wachtwoord gegenereerd in het wachtwoordveld.",
|
||||
"required": "Gebruikersnaam, e-mail en wachtwoord zijn verplicht.",
|
||||
"invalidUsername": "Gebruik 3–20 tekens: letters, cijfers, _ of -.",
|
||||
"passwordLength": "Het wachtwoord moet minimaal 6 tekens bevatten.",
|
||||
"created": "Gebruiker {username} aangemaakt."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overzicht",
|
||||
"hint": "Maximaal 5 recente registraties per bron. Alleen onderdelen waarvoor je toegang hebt worden getoond.",
|
||||
"restricted": "Je hebt geen toestemming om operationele gegevens te bekijken.",
|
||||
"sanctions": "Recente bans",
|
||||
"mute": "Actieve mute",
|
||||
"tickets": "Supporttickets",
|
||||
"help": "Helpcentrumverzoeken",
|
||||
"reports": "Meldingen in het spel",
|
||||
"transactions": "Betalingen",
|
||||
"purchases": "Catalogusaankopen",
|
||||
"activity": "Geregistreerde stafacties",
|
||||
"openModule": "Beheer openen",
|
||||
"purchaseHint": "Geregistreerde aantallen en valutakosten.",
|
||||
"purchaseDetail": "Aantal: {amount} · Credits: {credits} · Punten: {points} (type {type})",
|
||||
"activityHint": "Staflogboekregels over dit account. Dit is geen volledige geschiedenis van de activiteit van de speler.",
|
||||
"muteUntil": "Gedempt tot {date}",
|
||||
"error": "Deze bron kon niet worden geladen. Vernieuw de pagina om het opnieuw te proberen.",
|
||||
"empty": "Geen registraties gevonden.",
|
||||
"status": {
|
||||
"active": "Actief",
|
||||
"expired": "Verlopen",
|
||||
"open": "Open",
|
||||
"closed": "Gesloten",
|
||||
"reported": "Gemelde gebruiker",
|
||||
"reporter": "Melder"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3123,7 +3228,7 @@
|
||||
"radioSubtitle": "Stream, DJ-tools, embed en monitoring",
|
||||
"settings": "Instellingen",
|
||||
"settingsSubtitle": "CMS-configuratie, thema, onderhoud en gerelateerde tools",
|
||||
"tools": "Tools",
|
||||
"tools": "Hulpmiddelen",
|
||||
"toolsSubtitle": "RCON, kamers, import en vertalingen",
|
||||
"observability": "Observeerbaarheid",
|
||||
"observabilitySubtitle": "Logs, analytics, DevOps en hotelmeldingen",
|
||||
@@ -3134,10 +3239,10 @@
|
||||
"ads": "Advertenties",
|
||||
"media": "Media",
|
||||
"navigator": "Navigator",
|
||||
"help": "Help",
|
||||
"help": "Ondersteuning",
|
||||
"boxes": "Boxen",
|
||||
"tags": "Tags",
|
||||
"prefixes": "Prefixes",
|
||||
"prefixes": "Voorvoegsels",
|
||||
"events": "Evenementen",
|
||||
"eventTypes": "Evenementtypen",
|
||||
"polls": "Polls",
|
||||
@@ -3145,21 +3250,21 @@
|
||||
"helpTickets": "Helpcentrum",
|
||||
"templates": "Sjablonen",
|
||||
"directory": "Adresboek",
|
||||
"multiAccounts": "Multi-accounts",
|
||||
"multiAccounts": "Gekoppelde accounts",
|
||||
"online": "Online",
|
||||
"applications": "Sollicitaties",
|
||||
"teams": "Teams",
|
||||
"bans": "Bans",
|
||||
"bans": "Verbanningen",
|
||||
"ip": "IP",
|
||||
"vpn": "VPN",
|
||||
"permissions": "Permissies",
|
||||
"wordFilter": "Woordfilter",
|
||||
"overview": "Overzicht",
|
||||
"actions": "Acties",
|
||||
"cfh": "CFH",
|
||||
"cfh": "Moderatieverzoeken",
|
||||
"team": "Team",
|
||||
"catalogTab": "Catalogus",
|
||||
"rares": "Rares",
|
||||
"rares": "Zeldzame meubels",
|
||||
"badges": "Badges",
|
||||
"achievements": "Prestaties",
|
||||
"sounds": "Geluiden",
|
||||
@@ -3170,9 +3275,9 @@
|
||||
"calendar": "Kalender",
|
||||
"history": "Geschiedenis",
|
||||
"moderationTab": "Moderatie",
|
||||
"monitoring": "Monitoring",
|
||||
"monitoring": "Bewaking",
|
||||
"settingsTab": "Instellingen",
|
||||
"embed": "Embed",
|
||||
"embed": "Insluiten",
|
||||
"apiKeys": "API-sleutels",
|
||||
"points": "Punten",
|
||||
"ranks": "Rangen",
|
||||
@@ -3185,35 +3290,36 @@
|
||||
"emulator": "Emulator",
|
||||
"email": "E-mail",
|
||||
"housekeeping": "Permissie-archief",
|
||||
"commando": "Commando",
|
||||
"commando": "Operationeel centrum",
|
||||
"rooms": "Kamers",
|
||||
"import": "Import",
|
||||
"import": "Importeren",
|
||||
"studio": "Studio",
|
||||
"importOverview": "Overzicht",
|
||||
"importBadges": "Badges",
|
||||
"importFurni": "Meubels",
|
||||
"importUpload": "Upload",
|
||||
"importUpload": "Uploaden",
|
||||
"importClothing": "Kleding",
|
||||
"importEffects": "Effecten",
|
||||
"importPets": "Huisdieren",
|
||||
"importClone": "Kloon",
|
||||
"importSync": "Sync",
|
||||
"importSync": "Synchronisatie",
|
||||
"importRepairIcons": "Pictogrammen repareren",
|
||||
"importAudit": "Audit",
|
||||
"importAudit": "Gegevenscontrole",
|
||||
"translations": "Vertalingen",
|
||||
"logs": "Logs",
|
||||
"audit": "Audit",
|
||||
"logs": "Logboeken",
|
||||
"audit": "Wijzigingslogboek",
|
||||
"chat": "Chat",
|
||||
"commands": "Commands",
|
||||
"trades": "Trades",
|
||||
"analytics": "Analytics",
|
||||
"commands": "Commando's",
|
||||
"trades": "Ruiltransacties",
|
||||
"analytics": "Statistieken",
|
||||
"activity": "Activiteit",
|
||||
"economy": "Economie",
|
||||
"devops": "DevOps",
|
||||
"errors": "Fouten",
|
||||
"alerts": "Meldingen",
|
||||
"toolsGroup": "Tools",
|
||||
"ticketInbox": "Inbox"
|
||||
"toolsGroup": "Hulpmiddelen",
|
||||
"ticketInbox": "Ontvangen tickets",
|
||||
"cmsErrors": "CMS-fouten"
|
||||
},
|
||||
"events": "Evenementen",
|
||||
"eventsSubtitle": "Hotel-evenementen en evenementtypes",
|
||||
@@ -3221,11 +3327,11 @@
|
||||
"ticketsSubtitle": "Twee aparte wachtrijen: CMS support desk en publieke helpcenter-tickets",
|
||||
"staffAccess": "Rangen & rechten",
|
||||
"staffAccessSubtitle": "Staffrangen en live toegang. Archief is alleen vergelijken/exporteren.",
|
||||
"analytics": "Analytics",
|
||||
"analytics": "Statistieken",
|
||||
"analyticsSubtitle": "Hotelactiviteit, retentie en economie",
|
||||
"devops": "DevOps",
|
||||
"devopsSubtitle": "Servicegezondheid en emulatorfouten",
|
||||
"import": "Import",
|
||||
"import": "Importeren",
|
||||
"importSubtitle": "Meubels, badges, kleding en asset-tools"
|
||||
},
|
||||
"tickets": {
|
||||
@@ -3275,19 +3381,19 @@
|
||||
"actionsTitle": "Acties",
|
||||
"requesterTitle": "Aanvrager",
|
||||
"detailsTitle": "Details",
|
||||
"rankLabel": "Rank {rank}",
|
||||
"rankLabel": "Rang {rank}",
|
||||
"messageCountLabel": "Berichten",
|
||||
"success": {
|
||||
"replied": "Antwoord verzonden.",
|
||||
"closed": "Ticket gesloten.",
|
||||
"reopened": "Ticket heropend.",
|
||||
"banLifted": "Ban lifted and ticket closed."
|
||||
"banLifted": "Verbanning opgeheven en ticket gesloten."
|
||||
},
|
||||
"queueHint": "Deze wachtrij is het publieke helpcenter. CMS support desk tickets staan onder Supportbalie.",
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open hier: {here} · Supportbalie: {other}",
|
||||
"liftBanSubmit": "Lift requester ban & close",
|
||||
"requesterBanned": "Requester banned"
|
||||
"liftBanSubmit": "Verbanning van aanvrager opheffen en sluiten",
|
||||
"requesterBanned": "Aanvrager is verbannen"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -3583,6 +3689,25 @@
|
||||
"specialPricing": "Speciale prijs: {credits} credits, {points} punten (type {type})",
|
||||
"restrictedOffers": "{count} aanbiedingen in deze categorie vereisen een Club-lidmaatschap.",
|
||||
"clubOffer": "Club-aanbieding"
|
||||
},
|
||||
"actions": {
|
||||
"saved": "Wijzigingen opgeslagen.",
|
||||
"error": "De bewerking kon niet worden voltooid.",
|
||||
"saving": "Bezig met opslaan…",
|
||||
"saveError": "Opslaan mislukt. Je wijzigingen zijn behouden; probeer het opnieuw.",
|
||||
"unsaved": "Niet-opgeslagen wijzigingen",
|
||||
"leaveUnsaved": "Je hebt niet-opgeslagen wijzigingen. Wil je deze pagina verlaten?",
|
||||
"preview": "Voorbeeld"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Meubels",
|
||||
"catalog": "Cataloguscategorieën",
|
||||
"tickets": "Supporttickets",
|
||||
"loading": "Zoeken…",
|
||||
"hint": "Voer een numeriek ID of minstens 2 tekens in, of kies een pagina.",
|
||||
"retry": "Opnieuw proberen",
|
||||
"description": "Zoek pagina’s, gebruikers, meubels, cataloguscategorieën, artikelen en supporttickets.",
|
||||
"partialError": "Sommige resultaten konden niet worden geladen. Probeer opnieuw."
|
||||
}
|
||||
},
|
||||
"mod": {
|
||||
|
||||
@@ -1232,6 +1232,84 @@
|
||||
"save": "Lagre endringer",
|
||||
"saving": "Lagrer …",
|
||||
"cancel": "Avbryt"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3237,6 +3315,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1233,6 +1233,84 @@
|
||||
"save": "Zapisz zmiany",
|
||||
"saving": "Zapisuję…",
|
||||
"cancel": "Anuluj"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3238,6 +3316,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1233,6 +1233,84 @@
|
||||
"save": "Salvar alterações",
|
||||
"saving": "Salvando…",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3238,6 +3316,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1232,6 +1232,84 @@
|
||||
"save": "Salvați modificările",
|
||||
"saving": "Se salvează…",
|
||||
"cancel": "Anulează"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3237,6 +3315,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1231,6 +1231,84 @@
|
||||
"save": "Save changes",
|
||||
"saving": "Saving…",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3236,6 +3314,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1232,6 +1232,84 @@
|
||||
"save": "Uložiť zmeny",
|
||||
"saving": "Ukladá sa…",
|
||||
"cancel": "Zrušiť"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3237,6 +3315,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1232,6 +1232,84 @@
|
||||
"save": "Сачувај промене",
|
||||
"saving": "Чување…",
|
||||
"cancel": "Откажи"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3237,6 +3315,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1232,6 +1232,84 @@
|
||||
"save": "Spara ändringar",
|
||||
"saving": "Sparar...",
|
||||
"cancel": "Avbryt"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3237,6 +3315,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1233,6 +1233,84 @@
|
||||
"save": "Değişiklikleri kaydet",
|
||||
"saving": "Kaydediliyor…",
|
||||
"cancel": "İptal"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3238,6 +3316,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1232,6 +1232,84 @@
|
||||
"save": "Зберегти зміни",
|
||||
"saving": "Збереження…",
|
||||
"cancel": "Скасувати"
|
||||
},
|
||||
"presentation": {
|
||||
"users": "Users",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"rank": "Rank {rank}",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Banned",
|
||||
"credits": "credits",
|
||||
"diamonds": "diamonds",
|
||||
"duckets": "duckets",
|
||||
"pixels": "pixels",
|
||||
"rooms": "Rooms",
|
||||
"messages": "Messages",
|
||||
"trades": "Trades",
|
||||
"accountDetails": "Account details",
|
||||
"registered": "Registered",
|
||||
"lastLogin": "Last login",
|
||||
"ipRegister": "Registration IP",
|
||||
"ipCurrent": "Current IP",
|
||||
"machineId": "Device ID",
|
||||
"email": "Email",
|
||||
"gender": "Gender",
|
||||
"never": "Never",
|
||||
"findIp": "Find users with this IP",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Not available",
|
||||
"noBadges": "No badges assigned"
|
||||
},
|
||||
"create": {
|
||||
"newUser": "New user",
|
||||
"title": "Create user",
|
||||
"description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rank": "Rank",
|
||||
"rankOption": "Rank {rank}",
|
||||
"motto": "Motto (optional)",
|
||||
"usernamePlaceholder": "NewHabbo",
|
||||
"passwordPlaceholder": "At least 6 characters",
|
||||
"mottoPlaceholder": "I'm new here!",
|
||||
"generate": "Generate",
|
||||
"passwordGenerated": "Password generated in the password field.",
|
||||
"required": "Username, email and password are required.",
|
||||
"invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.",
|
||||
"passwordLength": "The password must contain at least 6 characters.",
|
||||
"created": "User {username} created."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"hint": "Up to 5 recent records per source. Only sections you can access are shown.",
|
||||
"restricted": "You do not have permission to view operational records.",
|
||||
"sanctions": "Recent bans",
|
||||
"mute": "Active mute",
|
||||
"tickets": "Support tickets",
|
||||
"help": "Help center requests",
|
||||
"reports": "In-game reports",
|
||||
"transactions": "Payments",
|
||||
"purchases": "Catalog purchases",
|
||||
"activity": "Recorded staff actions",
|
||||
"openModule": "Open management",
|
||||
"purchaseHint": "Recorded quantities and currency costs.",
|
||||
"purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})",
|
||||
"activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.",
|
||||
"muteUntil": "Muted until {date}",
|
||||
"error": "This source could not be loaded. Refresh the page to try again.",
|
||||
"empty": "No records found.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reported": "Reported user",
|
||||
"reporter": "Reporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
@@ -3237,6 +3315,16 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"search": {
|
||||
"furniture": "Furniture",
|
||||
"catalog": "Catalog categories",
|
||||
"tickets": "Support tickets",
|
||||
"loading": "Searching…",
|
||||
"hint": "Enter a numeric ID or at least 2 characters, or choose a page.",
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
Reference in new issue
Block a user