feat(hk): expand search and add operational user overview
CI / check (push) Successful in 54s
CI / deploy (push) Successful in 1m16s
CI / publish-container (push) Successful in 1m57s

This commit is contained in:
Simo committed 2026-09-09 21:14:10 +02:00
1 parent 27447ce029
commit 89526af344
44 files changed
+3740 -416

No files matched your search

@@ -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 (
+45 -23
View File
@@ -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);
});
+63 -34
View File
@@ -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,
};
+58 -28
View File
@@ -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} />
+41 -23
View File
@@ -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 && (
+247
View File
@@ -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
View File
@@ -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 });
},
);
+74 -26
View File
@@ -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) {
+3
View File
@@ -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" }];
+22
View File
@@ -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);
});
});
+20
View File
@@ -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;
}
}
+6 -4
View File
@@ -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)", () => {
+90
View File
@@ -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": {
+88
View File
@@ -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": {
+88
View File
@@ -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": {
+88
View File
@@ -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": {
+88
View File
@@ -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": {
+88
View File
@@ -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
View File
@@ -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…",
+88
View File
@@ -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": {
+90
View File
@@ -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": {
+88
View File
@@ -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": {
+88
View File
@@ -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": {
+88
View File
@@ -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
View File
@@ -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…",
+90
View File
@@ -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
View File
@@ -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": {
+88
View File
@@ -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": {
+88
View File
@@ -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": {
+88
View File
@@ -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": {
+88
View File
@@ -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": {
+88
View File
@@ -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": {
+88
View File
@@ -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": {
+88
View File
@@ -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": {
+88
View File
@@ -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": {
+88
View File
@@ -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": {
+88
View File
@@ -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": {