feat(hk): expand search and add operational user overview

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

+20
View File
@@ -81,3 +81,23 @@ Validation: 1,506 tests passed (six skipped), type checking, lint, translation
contracts and a production build with fixture configuration. A browser fixture contracts and a production build with fixture configuration. A browser fixture
verified the real dialog at 1280 and 390 pixels; server actions were simulated. verified the real dialog at 1280 and 390 pixels; server actions were simulated.
The new database migration and package publication have not run in production. The new database migration and package publication have not run in production.
## Housekeeping search and user overview
The existing global search now includes furniture, normal/Club catalog categories
and both ticket sources. Results respect module permissions, accept single-digit
IDs, and remain usable when one source fails. Keyboard navigation and cancellation
prevent stale search responses from replacing newer results.
User details open on an operational overview with up to five records from each
authorized source: bans, active mute, support tickets, help tickets, reports,
payments, catalog purchases and audit activity. Failed sources are distinguished
from empty results. User detail and edit pages also apply log permissions before
loading activity and exposing counters.
Italian and Dutch navigation, user management, news and support labels were
reviewed. The new search and overview copy has English, Italian and Dutch text;
other locales receive English fallback strings. This is a focused editorial pass,
not a full translation of every CMS page. No database migration or dependency
change is required. Browser checks use real components with simulated data at
1280 and 390 pixels; production database behavior still needs deployment validation.
@@ -1,6 +1,7 @@
"use client"; "use client";
import { Loader2, UserPlus } from "lucide-react"; import { Loader2, UserPlus } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { createUser } from "@/actions/users"; import { createUser } from "@/actions/users";
@@ -28,17 +29,10 @@ interface CreateUserDialogProps {
sessionRank: number; sessionRank: number;
} }
const RANK_OPTIONS: { value: number; label: string }[] = [ const RANK_OPTIONS = [1, 2, 3, 4, 5, 6, 7];
{ 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)" },
];
export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) { export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
const t = useTranslations("pages.admin.users.create");
const { run, isPending } = useServerAction(); const { run, isPending } = useServerAction();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@@ -48,7 +42,7 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
const [motto, setMotto] = useState(""); const [motto, setMotto] = useState("");
const allowedRanks = RANK_OPTIONS.filter((r) => const allowedRanks = RANK_OPTIONS.filter((r) =>
sessionRank >= 7 ? true : r.value < sessionRank, sessionRank >= 7 ? true : r < sessionRank,
); );
function reset() { function reset() {
@@ -67,22 +61,22 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
for (let i = 0; i < buf.length; i++) for (let i = 0; i < buf.length; i++)
out += charset[buf[i] % charset.length]; out += charset[buf[i] % charset.length];
setPassword(out); setPassword(out);
toast.message("Password generated", { description: out }); toast.message(t("passwordGenerated"));
} }
function handleCreate() { function handleCreate() {
const u = username.trim(); const u = username.trim();
const m = mail.trim(); const m = mail.trim();
if (!u || !m || !password) { if (!u || !m || !password) {
toast.error("Username, email and password are required"); toast.error(t("required"));
return; return;
} }
if (u.length < 3 || u.length > 20 || !/^[a-zA-Z0-9_-]+$/.test(u)) { 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; return;
} }
if (password.length < 6) { if (password.length < 6) {
toast.error("Password must be at least 6 characters"); toast.error(t("passwordLength"));
return; return;
} }
@@ -96,7 +90,7 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
motto: motto.trim(), motto: motto.trim(),
}), }),
{ {
successMessage: `User "${u}" created.`, successMessage: t("created", { username: u }),
onSuccess: () => { onSuccess: () => {
setOpen(false); setOpen(false);
reset(); reset();
@@ -116,31 +110,28 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button>
<UserPlus className="mr-2 h-4 w-4" /> <UserPlus className="mr-2 h-4 w-4" />
New User {t("newUser")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Create User</DialogTitle> <DialogTitle>{t("title")}</DialogTitle>
<DialogDescription> <DialogDescription>{t("description")}</DialogDescription>
Account is created immediately with default look and 5,000
credits/duckets/diamonds.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cu-username">Username</Label> <Label htmlFor="cu-username">{t("username")}</Label>
<Input <Input
id="cu-username" id="cu-username"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
placeholder="HabboName" placeholder={t("usernamePlaceholder")}
autoComplete="off" autoComplete="off"
maxLength={20} maxLength={20}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cu-mail">Email</Label> <Label htmlFor="cu-mail">{t("email")}</Label>
<Input <Input
id="cu-mail" id="cu-mail"
type="email" type="email"
@@ -151,14 +142,14 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cu-password">Password</Label> <Label htmlFor="cu-password">{t("password")}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
id="cu-password" id="cu-password"
type="text" type="text"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Minimum 6 characters" placeholder={t("passwordPlaceholder")}
autoComplete="new-password" autoComplete="new-password"
className="font-mono" className="font-mono"
/> />
@@ -167,33 +158,33 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
variant="outline" variant="outline"
onClick={generatePassword} onClick={generatePassword}
> >
Generate {t("generate")}
</Button> </Button>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Rank</Label> <Label>{t("rank")}</Label>
<Select value={rank} onValueChange={setRank}> <Select value={rank} onValueChange={setRank}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{allowedRanks.map((r) => ( {allowedRanks.map((r) => (
<SelectItem key={r.value} value={String(r.value)}> <SelectItem key={r} value={String(r)}>
{r.label} {t("rankOption", { rank: r })}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cu-motto">Motto (optional)</Label> <Label htmlFor="cu-motto">{t("motto")}</Label>
<Input <Input
id="cu-motto" id="cu-motto"
value={motto} value={motto}
onChange={(e) => setMotto(e.target.value)} onChange={(e) => setMotto(e.target.value)}
maxLength={127} maxLength={127}
placeholder="I'm new here!" placeholder={t("mottoPlaceholder")}
/> />
</div> </div>
@@ -207,7 +198,7 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
) : ( ) : (
<UserPlus className="mr-2 h-4 w-4" /> <UserPlus className="mr-2 h-4 w-4" />
)} )}
Create User {t("title")}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -6,6 +6,7 @@ import {
MonitorSmartphone, MonitorSmartphone,
Shield, Shield,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "@/components/link"; import Link from "@/components/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -25,40 +26,41 @@ interface UserAccountInfoProps {
} }
export function UserAccountInfo({ user }: UserAccountInfoProps) { export function UserAccountInfo({ user }: UserAccountInfoProps) {
const t = useTranslations("pages.admin.users.presentation");
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Account Details</CardTitle> <CardTitle>{t("accountDetails")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" /> <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"> <span className="ml-auto font-medium">
{formatDate(new Date(user.accountCreated * 1000), "date")} {formatDate(new Date(user.accountCreated * 1000), "date")}
</span> </span>
</div> </div>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<Clock className="h-4 w-4 text-muted-foreground shrink-0" /> <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"> <span className="ml-auto font-medium">
{user.lastLogin {user.lastLogin
? formatDate(new Date(user.lastLogin * 1000)) ? formatDate(new Date(user.lastLogin * 1000))
: "Never"} : t("never")}
</span> </span>
</div> </div>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<Globe className="h-4 w-4 text-muted-foreground shrink-0" /> <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"> <span className="ml-auto flex items-center gap-1.5">
<code className="text-xs font-mono bg-muted px-2 py-0.5 rounded"> <code className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
{user.ipRegister} {user.ipRegister}
</code> </code>
<Link <Link
href={`/admin/users?search=${user.ipRegister}`} href={`/admin/users?search=${user.ipRegister}`}
title="Find other users with this IP" title={t("findIp")}
> >
<ExternalLink className="h-3 w-3 text-primary" /> <ExternalLink className="h-3 w-3 text-primary" />
</Link> </Link>
@@ -66,14 +68,14 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
</div> </div>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<Globe className="h-4 w-4 text-muted-foreground shrink-0" /> <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"> <span className="ml-auto flex items-center gap-1.5">
<code className="text-xs font-mono bg-muted px-2 py-0.5 rounded"> <code className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
{user.ipCurrent} {user.ipCurrent}
</code> </code>
<Link <Link
href={`/admin/users?search=${user.ipCurrent}`} href={`/admin/users?search=${user.ipCurrent}`}
title="Find other users with this IP" title={t("findIp")}
> >
<ExternalLink className="h-3 w-3 text-primary" /> <ExternalLink className="h-3 w-3 text-primary" />
</Link> </Link>
@@ -88,22 +90,22 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
variant={user.twoFactorConfirmed ? "default" : "outline"} variant={user.twoFactorConfirmed ? "default" : "outline"}
className="ml-auto" className="ml-auto"
> >
{user.twoFactorConfirmed ? "Enabled" : "Disabled"} {user.twoFactorConfirmed ? t("enabled") : t("disabled")}
</Badge> </Badge>
</div> </div>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<MonitorSmartphone className="h-4 w-4 text-muted-foreground shrink-0" /> <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]"> <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> </code>
</div> </div>
<div className="flex items-center gap-3 text-sm"> <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> <span className="ml-auto font-medium">{user.mail}</span>
</div> </div>
<div className="flex items-center gap-3 text-sm"> <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"> <Badge variant="outline" className="ml-auto">
{user.gender} {user.gender}
</Badge> </Badge>
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
interface Badge { interface Badge {
id: number; id: number;
@@ -6,8 +7,9 @@ interface Badge {
} }
export function UserBadgesReadonly({ badges }: { badges: Badge[] }) { export function UserBadgesReadonly({ badges }: { badges: Badge[] }) {
const t = useTranslations("pages.admin.users.presentation");
if (badges.length === 0) { 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 ( return (
+45 -23
View File
@@ -10,6 +10,7 @@ import {
WifiOff, WifiOff,
} from "lucide-react"; } from "lucide-react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb"; import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import { UserWatchToggle } from "@/components/admin/user-watch-toggle"; import { UserWatchToggle } from "@/components/admin/user-watch-toggle";
import Link from "@/components/link"; import Link from "@/components/link";
@@ -42,6 +43,7 @@ interface UserHeroProps {
activeBan: boolean; activeBan: boolean;
mode: "show" | "edit"; mode: "show" | "edit";
canEdit: boolean; canEdit: boolean;
canViewLogs?: boolean;
/** When non-null, render the watch toggle with this initial state. */ /** When non-null, render the watch toggle with this initial state. */
watching?: boolean | null; watching?: boolean | null;
} }
@@ -58,7 +60,9 @@ export function UserHero({
mode, mode,
canEdit, canEdit,
watching, watching,
canViewLogs = true,
}: UserHeroProps) { }: UserHeroProps) {
const t = useTranslations("pages.admin.users.presentation");
const params = useSearchParams(); const params = useSearchParams();
const list = params.get("list") ?? ""; const list = params.get("list") ?? "";
const listHref = userListHref(list); const listHref = userListHref(list);
@@ -66,9 +70,9 @@ export function UserHero({
<> <>
<AdminBreadcrumb <AdminBreadcrumb
items={[ items={[
{ label: "Users", href: listHref }, { label: t("users"), href: listHref },
{ label: user.username }, { 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 && ( {mode === "show" && canEdit && (
<Button variant="outline" size="sm" asChild className="ml-2"> <Button variant="outline" size="sm" asChild className="ml-2">
<Link href={userDetailHref(user.id, "edit", list)}> <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> </Link>
</Button> </Button>
)} )}
{mode === "edit" && ( {mode === "edit" && (
<Button variant="outline" size="sm" asChild className="ml-2"> <Button variant="outline" size="sm" asChild className="ml-2">
<Link href={userDetailHref(user.id, "show", list)}> <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> </Link>
</Button> </Button>
)} )}
@@ -134,22 +138,24 @@ export function UserHero({
)} )}
<div className="flex flex-wrap items-center gap-2"> <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 <Badge
variant={isOnline ? "default" : "outline"} variant={isOnline ? "default" : "outline"}
className={isOnline ? "bg-[var(--admin-success-subtle)]" : ""} className={isOnline ? "bg-[var(--admin-success-subtle)]" : ""}
> >
{isOnline ? ( {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> </Badge>
{activeBan && <Badge variant="destructive">Banned</Badge>} {activeBan && <Badge variant="destructive">{t("banned")}</Badge>}
{user.twoFactorConfirmed ? ( {user.twoFactorConfirmed ? (
<Badge <Badge
variant="outline" variant="outline"
@@ -167,24 +173,32 @@ export function UserHero({
<span className="font-semibold"> <span className="font-semibold">
{formatNumber(user.credits)} {formatNumber(user.credits)}
</span> </span>
<span className="text-muted-foreground text-xs">credits</span> <span className="text-muted-foreground text-xs">
{t("credits")}
</span>
</div> </div>
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<CurrencyIcon kind="diamonds" size={18} /> <CurrencyIcon kind="diamonds" size={18} />
<span className="font-semibold">{formatNumber(diamonds)}</span> <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>
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<CurrencyIcon kind="duckets" size={18} /> <CurrencyIcon kind="duckets" size={18} />
<span className="font-semibold">{formatNumber(duckets)}</span> <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>
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<CurrencyIcon kind="pixels" size={18} /> <CurrencyIcon kind="pixels" size={18} />
<span className="font-semibold"> <span className="font-semibold">
{formatNumber(user.pixels)} {formatNumber(user.pixels)}
</span> </span>
<span className="text-muted-foreground text-xs">pixels</span> <span className="text-muted-foreground text-xs">
{t("pixels")}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -197,18 +211,26 @@ export function UserHero({
> >
<DoorOpen className="h-4 w-4 mx-auto text-muted-foreground" /> <DoorOpen className="h-4 w-4 mx-auto text-muted-foreground" />
<p className="text-lg font-bold">{roomCount}</p> <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> </Link>
<div className="rounded-lg border bg-card px-3 py-2 text-center"> {canViewLogs && (
<MessageSquare className="h-4 w-4 mx-auto text-muted-foreground" /> <div className="rounded-lg border bg-card px-3 py-2 text-center">
<p className="text-lg font-bold">{formatNumber(chatCount)}</p> <MessageSquare className="h-4 w-4 mx-auto text-muted-foreground" />
<p className="text-[10px] text-muted-foreground">Messages</p> <p className="text-lg font-bold">{formatNumber(chatCount)}</p>
</div> <p className="text-[10px] text-muted-foreground">
<div className="rounded-lg border bg-card px-3 py-2 text-center"> {t("messages")}
<ArrowLeftRight className="h-4 w-4 mx-auto text-muted-foreground" /> </p>
<p className="text-lg font-bold">{tradeCount}</p> </div>
<p className="text-[10px] text-muted-foreground">Trades</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> </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 ?? []; return rows ?? [];
} }
export async function loadUserById(id: string) { export async function loadUserById(
id: string,
access: { logs: boolean; bans: boolean },
) {
const numericId = Number(id); const numericId = Number(id);
const isNumeric = const isNumeric =
Number.isSafeInteger(numericId) && Number.isSafeInteger(numericId) &&
@@ -44,6 +47,7 @@ export async function loadUserById(id: string) {
.limit(1); .limit(1);
if (!user) notFound(); if (!user) notFound();
let activityUnavailable = false;
const [ const [
currencies, currencies,
badges, badges,
@@ -64,30 +68,40 @@ export async function loadUserById(id: string) {
.from(UsersBadges) .from(UsersBadges)
.where(eq(UsersBadges.userId, user.id)) .where(eq(UsersBadges.userId, user.id))
.orderBy(asc(UsersBadges.slotId)), .orderBy(asc(UsersBadges.slotId)),
rawRows<{ access.bans
id: number; ? rawRows<{
banReason: string; id: number;
type: string; banReason: string;
timestamp: number; type: string;
banExpire: number; timestamp: number;
staffName: string; banExpire: number;
}>(sql` staffName: string;
}>(sql`
SELECT b.id, b.ban_reason AS banReason, b.type, b.timestamp, b.ban_expire AS banExpire, SELECT b.id, b.ban_reason AS banReason, b.type, b.timestamp, b.ban_expire AS banExpire,
COALESCE(s.username, 'Unknown') AS staffName COALESCE(s.username, 'Unknown') AS staffName
FROM bans b LEFT JOIN users s ON s.id = b.user_staff_id 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` rawRows<{ rankName: string }>(sql`
SELECT COALESCE(rank_name, CONCAT('Rank ', id)) AS rankName FROM permissions WHERE id = ${user.rank} LIMIT 1`), 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(Rooms).where(eq(Rooms.ownerId, user.id)),
db access.logs
.select({ total: count() }) ? db
.from(RoomTradeLog) .select({ total: count() })
.where( .from(RoomTradeLog)
or( .where(
eq(RoomTradeLog.userOneId, user.id), or(
eq(RoomTradeLog.userTwoId, user.id), eq(RoomTradeLog.userOneId, user.id),
), eq(RoomTradeLog.userTwoId, user.id),
), ),
)
.catch(() => {
activityUnavailable = true;
return [];
})
: [],
db db
.select({ .select({
id: Rooms.id, id: Rooms.id,
@@ -100,23 +114,37 @@ export async function loadUserById(id: string) {
.where(eq(Rooms.ownerId, user.id)) .where(eq(Rooms.ownerId, user.id))
.orderBy(desc(Rooms.users)) .orderBy(desc(Rooms.users))
.limit(20), .limit(20),
rawRows<{ access.logs
id: number; ? rawRows<{
roomId: number; id: number;
timestamp: number; roomId: number;
message: string; timestamp: number;
}>(sql` message: string;
}>(sql`
SELECT ROW_NUMBER() OVER (ORDER BY timestamp DESC) AS id, room_id AS roomId, timestamp, message 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`), FROM chatlogs_room WHERE user_from_id = ${user.id} ORDER BY timestamp DESC LIMIT 20`).catch(
rawRows<{ () => {
id: number; activityUnavailable = true;
timestamp: number; return [];
command: string; },
params: string; )
succes: string; : [],
}>(sql` 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 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); const roomCount = Number(roomCountRows[0]?.total ?? 0);
@@ -157,6 +185,7 @@ export async function loadUserById(id: string) {
userRooms, userRooms,
chatLogs, chatLogs,
commandLogs, commandLogs,
activityUnavailable,
isOnline: user.online === "1", isOnline: user.online === "1",
activeBan, activeBan,
}; };
+58 -28
View File
@@ -1,5 +1,6 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
@@ -25,7 +26,18 @@ export default async function UserEditPage({
redirect("/admin"); 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); const watched = await getWatchedUserIds(session.user.id);
// Fetch rank list from permissions table for the rank dropdown // Fetch rank list from permissions table for the rank dropdown
@@ -45,6 +57,7 @@ export default async function UserEditPage({
duckets={data.duckets} duckets={data.duckets}
roomCount={data.roomCount} roomCount={data.roomCount}
chatCount={data.chatCount} chatCount={data.chatCount}
canViewLogs={canViewLogs && !data.activityUnavailable}
tradeCount={data.tradeCount} tradeCount={data.tradeCount}
isOnline={data.isOnline} isOnline={data.isOnline}
activeBan={!!data.activeBan} activeBan={!!data.activeBan}
@@ -57,17 +70,21 @@ export default async function UserEditPage({
<Tabs defaultValue="edit" className="space-y-4 min-w-0"> <Tabs defaultValue="edit" className="space-y-4 min-w-0">
<TabsList className="flex-wrap"> <TabsList className="flex-wrap">
<TabsTrigger value="edit">Edit Profile</TabsTrigger> <TabsTrigger value="edit">Edit Profile</TabsTrigger>
<TabsTrigger value="bans"> {canViewBans && (
Bans <TabsTrigger value="bans">
{data.activeBan && ( Bans
<span className="ml-1.5 flex h-2 w-2 rounded-full bg-destructive" /> {data.activeBan && (
)} <span className="ml-1.5 flex h-2 w-2 rounded-full bg-destructive" />
</TabsTrigger> )}
</TabsTrigger>
)}
<TabsTrigger value="badges"> <TabsTrigger value="badges">
Badges ({data.user.badges.length}) Badges ({data.user.badges.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="rooms">Rooms ({data.roomCount})</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> <TabsTrigger value="info">Account Info</TabsTrigger>
</TabsList> </TabsList>
@@ -88,20 +105,22 @@ export default async function UserEditPage({
/> />
</TabsContent> </TabsContent>
<TabsContent value="bans"> {canViewBans && (
<UserBanSection <TabsContent value="bans">
userId={data.user.id} <UserBanSection
username={data.user.username} userId={data.user.id}
bans={data.user.bansReceived.map((b) => ({ username={data.user.username}
id: b.id, bans={data.user.bansReceived.map((b) => ({
reason: b.banReason, id: b.id,
type: b.type, reason: b.banReason,
staffName: b.staff.username, type: b.type,
timestamp: b.timestamp, staffName: b.staff.username,
banExpire: b.banExpire, timestamp: b.timestamp,
}))} banExpire: b.banExpire,
/> }))}
</TabsContent> />
</TabsContent>
)}
<TabsContent value="badges"> <TabsContent value="badges">
<UserBadgesSection <UserBadgesSection
@@ -117,12 +136,23 @@ export default async function UserEditPage({
<UserRooms rooms={data.userRooms} /> <UserRooms rooms={data.userRooms} />
</TabsContent> </TabsContent>
<TabsContent value="activity"> {canViewLogs && (
<UserActivityLog <TabsContent value="activity">
chatLogs={data.chatLogs} {data.activityUnavailable ? (
commandLogs={data.commandLogs} <p
/> role="status"
</TabsContent> 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"> <TabsContent value="info">
<UserAccountInfo user={data.user} /> <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 { UserAccountInfo } from "../../_components/user-account-info";
import { UserBadgesReadonly } from "../../_components/user-badges-readonly"; import { UserBadgesReadonly } from "../../_components/user-badges-readonly";
import { UserHero } from "../../_components/user-hero"; 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 { 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 { UserActivityLog } from "../../[id]/user-activity-log";
import { UserQuickActions } from "../../[id]/user-quick-actions"; import { UserQuickActions } from "../../[id]/user-quick-actions";
import { UserRooms } from "../../[id]/user-rooms"; import { UserRooms } from "../../[id]/user-rooms";
@@ -25,10 +25,19 @@ export default async function UserShowPage({
} }
const t = await getTranslations("pages.admin.users.detail"); 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 canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
const data = await loadUserById(id); const canViewLogs = canAccess(
const [sanctions, watched] = await Promise.all([ permissions,
loadUserSanctions(data.user.id), 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), getWatchedUserIds(session.user.id),
]); ]);
@@ -40,6 +49,7 @@ export default async function UserShowPage({
duckets={data.duckets} duckets={data.duckets}
roomCount={data.roomCount} roomCount={data.roomCount}
chatCount={data.chatCount} chatCount={data.chatCount}
canViewLogs={canViewLogs && !data.activityUnavailable}
tradeCount={data.tradeCount} tradeCount={data.tradeCount}
isOnline={data.isOnline} isOnline={data.isOnline}
activeBan={!!data.activeBan} activeBan={!!data.activeBan}
@@ -55,8 +65,9 @@ export default async function UserShowPage({
: undefined : 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"> <TabsList className="h-auto flex-wrap justify-start">
<TabsTrigger value="overview">{overviewT("title")}</TabsTrigger>
<TabsTrigger value="info">{t("tabInfo")}</TabsTrigger> <TabsTrigger value="info">{t("tabInfo")}</TabsTrigger>
<TabsTrigger value="badges"> <TabsTrigger value="badges">
{t("tabBadges", { count: data.user.badges.length })} {t("tabBadges", { count: data.user.badges.length })}
@@ -64,12 +75,15 @@ export default async function UserShowPage({
<TabsTrigger value="rooms"> <TabsTrigger value="rooms">
{t("tabRooms", { count: data.roomCount })} {t("tabRooms", { count: data.roomCount })}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="sanctions"> {canViewLogs && (
{t("tabSanctions", { count: sanctions.events.length })} <TabsTrigger value="activity">{t("tabActivity")}</TabsTrigger>
</TabsTrigger> )}
<TabsTrigger value="activity">{t("tabActivity")}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="overview">
<UserOperationalOverview sections={overview} />
</TabsContent>
<TabsContent value="info"> <TabsContent value="info">
<UserAccountInfo user={data.user} /> <UserAccountInfo user={data.user} />
</TabsContent> </TabsContent>
@@ -87,19 +101,23 @@ export default async function UserShowPage({
<UserRooms rooms={data.userRooms} /> <UserRooms rooms={data.userRooms} />
</TabsContent> </TabsContent>
<TabsContent value="sanctions"> {canViewLogs && (
<UserSanctionTimeline <TabsContent value="activity">
events={sanctions.events} {data.activityUnavailable ? (
muteUntil={sanctions.muteUntil} <p
/> role="status"
</TabsContent> className="rounded-lg border bg-card p-4 text-sm text-destructive"
>
<TabsContent value="activity"> {overviewT("error")}
<UserActivityLog </p>
chatLogs={data.chatLogs} ) : (
commandLogs={data.commandLogs} <UserActivityLog
/> chatLogs={data.chatLogs}
</TabsContent> commandLogs={data.commandLogs}
/>
)}
</TabsContent>
)}
</Tabs> </Tabs>
{canEdit && ( {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 { NextResponse } from "next/server";
import { ADMIN_NAV_GROUPS, navItemIsAllowed } from "@/lib/admin-nav"; import { ADMIN_NAV_GROUPS, navItemIsAllowed } from "@/lib/admin-nav";
import { apiOk } from "@/lib/api"; import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { import {
CatalogPages,
CatalogPagesBc,
db, db,
Guilds, Guilds,
ItemsBase,
Rooms, Rooms,
User, User,
WebsiteArticles, WebsiteArticles,
WebsiteHelpCenterTickets,
WebsiteRareValues, WebsiteRareValues,
WebsiteShopArticles, WebsiteShopArticles,
WebsiteTicket,
} from "@/lib/db"; } from "@/lib/db";
import { canAccess, PERMS } from "@/lib/permissions"; import { canAccess, PERMS } from "@/lib/permissions";
import { rateLimit } from "@/lib/rate-limit"; import { rateLimit } from "@/lib/rate-limit";
export type AdminSearchResult = { export type AdminSearchResult = {
type: string; type: string;
id: number | string; id: number | string;
@@ -22,16 +26,13 @@ export type AdminSearchResult = {
subtitle: string; subtitle: string;
url: string; url: string;
}; };
const PER_TYPE = 5; const PER_TYPE = 5;
const MAX_TOTAL = 20; const MAX_TOTAL = 20;
const MAX_QUERY_LENGTH = 64; const MAX_QUERY_LENGTH = 64;
/** Escape LIKE wildcards so user input can't widen the match. */ /** 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}`); return input.replace(/[\\%_]/g, (m) => `\\${m}`);
} }
export const GET = withAdmin( export const GET = withAdmin(
{ permission: PERMS.ADMIN_DASHBOARD }, { permission: PERMS.ADMIN_DASHBOARD },
async (request, context) => { async (request, context) => {
@@ -46,14 +47,12 @@ export const GET = withAdmin(
{ status: 429, headers: { "retry-after": String(limited.retryAfter) } }, { status: 429, headers: { "retry-after": String(limited.retryAfter) } },
); );
} }
const q = (request.nextUrl.searchParams.get("q") || "") const q = (request.nextUrl.searchParams.get("q") || "")
.trim() .trim()
.slice(0, MAX_QUERY_LENGTH); .slice(0, MAX_QUERY_LENGTH);
if (q.length < 2) { if (q.length < 2 && !/^[1-9]$/.test(q)) {
return apiOk({ results: [] }); return apiOk({ results: [] });
} }
const canSearch = (href: string) => { const canSearch = (href: string) => {
const item = ADMIN_NAV_GROUPS.flatMap((group) => group.items).find( const item = ADMIN_NAV_GROUPS.flatMap((group) => group.items).find(
(item) => item.href === href, (item) => item.href === href,
@@ -67,123 +66,272 @@ export const GET = withAdmin(
}) })
); );
}; };
const pattern = `%${escapeLike(q)}%`; const pattern = `%${escapeLike(q)}%`;
const idExact = Number.parseInt(q, 10); const idExact = Number.parseInt(q, 10);
const hasId = Number.isFinite(idExact) && String(idExact) === q; const hasId =
Number.isSafeInteger(idExact) && idExact > 0 && String(idExact) === q;
const [users, articles, rooms, guilds, shopArticles, rareValues] = let partial = false;
await Promise.all([ const [
canSearch("/admin/users") users,
? db articles,
.select({ rooms,
id: User.id, guilds,
title: User.username, shopArticles,
subtitle: User.mail, rareValues,
}) furniture,
.from(User) catalog,
.where( catalogBc,
or( tickets,
like(User.username, pattern), helpTickets,
like(User.mail, pattern), ] = await Promise.all([
...(hasId ? [eq(User.id, idExact)] : []), canSearch("/admin/users")
), ? db
) .select({
.limit(PER_TYPE) id: User.id,
: Promise.resolve([]), title: User.username,
})
canSearch("/admin/articles") .from(User)
? db .where(
.select({ or(
id: WebsiteArticles.id, like(User.username, pattern),
title: WebsiteArticles.title, ...(hasId ? [eq(User.id, idExact)] : []),
subtitle: WebsiteArticles.slug, ),
}) )
.from(WebsiteArticles) .orderBy(asc(User.id))
.where( .limit(PER_TYPE)
or( .catch(() => {
like(WebsiteArticles.title, pattern), partial = true;
like(WebsiteArticles.slug, pattern), return [];
), })
) : Promise.resolve([]),
.limit(PER_TYPE) canSearch("/admin/articles")
: Promise.resolve([]), ? db
.select({
canSearch("/admin/rooms") id: WebsiteArticles.id,
? db title: WebsiteArticles.title,
.select({ subtitle: WebsiteArticles.slug,
id: Rooms.id, })
title: Rooms.name, .from(WebsiteArticles)
subtitle: Rooms.ownerName, .where(
}) or(
.from(Rooms) like(WebsiteArticles.title, pattern),
.where( like(WebsiteArticles.slug, pattern),
or( ),
like(Rooms.name, pattern), )
...(hasId ? [eq(Rooms.id, idExact)] : []), .orderBy(asc(WebsiteArticles.id))
), .limit(PER_TYPE)
) .catch(() => {
.limit(PER_TYPE) partial = true;
: Promise.resolve([]), return [];
})
canSearch("/admin/guilds") : Promise.resolve([]),
? db canSearch("/admin/rooms")
.select({ ? db
id: Guilds.id, .select({
title: Guilds.name, id: Rooms.id,
subtitle: Guilds.description, title: Rooms.name,
}) subtitle: Rooms.ownerName,
.from(Guilds) })
.where( .from(Rooms)
or( .where(
like(Guilds.name, pattern), or(
...(hasId ? [eq(Guilds.id, idExact)] : []), like(Rooms.name, pattern),
), ...(hasId ? [eq(Rooms.id, idExact)] : []),
) ),
.limit(PER_TYPE) )
: Promise.resolve([]), .orderBy(asc(Rooms.id))
.limit(PER_TYPE)
canSearch("/admin/shop") .catch(() => {
? db partial = true;
.select({ return [];
id: WebsiteShopArticles.id, })
title: WebsiteShopArticles.name, : Promise.resolve([]),
subtitle: WebsiteShopArticles.info, canSearch("/admin/guilds")
}) ? db
.from(WebsiteShopArticles) .select({
.where( id: Guilds.id,
or( title: Guilds.name,
like(WebsiteShopArticles.name, pattern), subtitle: Guilds.description,
...(hasId })
? [eq(WebsiteShopArticles.id, BigInt(idExact))] .from(Guilds)
: []), .where(
), or(
) like(Guilds.name, pattern),
.limit(PER_TYPE) ...(hasId ? [eq(Guilds.id, idExact)] : []),
: Promise.resolve([]), ),
)
canSearch("/admin/rare-values") .orderBy(asc(Guilds.id))
? db .limit(PER_TYPE)
.select({ .catch(() => {
id: WebsiteRareValues.id, partial = true;
title: WebsiteRareValues.name, return [];
subtitle: WebsiteRareValues.itemId, })
}) : Promise.resolve([]),
.from(WebsiteRareValues) canSearch("/admin/shop")
.where( ? db
or( .select({
like(WebsiteRareValues.name, pattern), id: WebsiteShopArticles.id,
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []), title: WebsiteShopArticles.name,
), subtitle: WebsiteShopArticles.info,
) })
.limit(PER_TYPE) .from(WebsiteShopArticles)
: Promise.resolve([]), .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< const groupMap: Record<
string, string,
{ type: string; items: Array<AdminSearchResult> } { type: string; items: Array<AdminSearchResult> }
> = { > = {
furniture: { type: "furniture", items: [] },
catalog: { type: "catalog", items: [] },
tickets: { type: "tickets", items: [] },
users: { type: "users", items: [] }, users: { type: "users", items: [] },
articles: { type: "articles", items: [] }, articles: { type: "articles", items: [] },
rooms: { type: "rooms", items: [] }, rooms: { type: "rooms", items: [] },
@@ -191,13 +339,12 @@ export const GET = withAdmin(
shop: { type: "shop", items: [] }, shop: { type: "shop", items: [] },
rareValues: { type: "rareValues", items: [] }, rareValues: { type: "rareValues", items: [] },
}; };
for (const r of users) { for (const r of users) {
groupMap.users.items.push({ groupMap.users.items.push({
type: "users", type: "users",
id: r.id, id: r.id,
title: r.title, title: r.title,
subtitle: r.subtitle || "", subtitle: `#${r.id}`,
url: `/admin/users/show/${r.id}`, url: `/admin/users/show/${r.id}`,
}); });
} }
@@ -246,10 +393,53 @@ export const GET = withAdmin(
url: `/admin/rare-values/${r.id}`, 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 results: AdminSearchResult[] = [];
const order = [ const order = [
"users", "users",
"furniture",
"catalog",
"tickets",
"articles", "articles",
"rooms", "rooms",
"guilds", "guilds",
@@ -268,7 +458,6 @@ export const GET = withAdmin(
} }
if (!added) break; if (!added) break;
} }
return apiOk({ results, partial });
return apiOk({ results });
}, },
); );
+74 -26
View File
@@ -18,18 +18,21 @@ import {
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogTitle, DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useDebounce } from "@/hooks/use-debounce"; import { useDebounce } from "@/hooks/use-debounce";
import { import {
type AdminNavigationOptions, type AdminNavigationOptions,
visibleAdminNavigation, visibleAdminNavigation,
} from "@/lib/admin-navigation"; } from "@/lib/admin-navigation";
import { AdminSearchRequest } from "@/lib/admin-search-request";
type SearchResult = AdminSearchResult; type SearchResult = AdminSearchResult;
type SearchResponse = { type SearchResponse = {
ok: boolean; ok: boolean;
results: SearchResult[]; results: SearchResult[];
partial?: boolean;
}; };
export function SearchDialog({ export function SearchDialog({
@@ -56,8 +59,9 @@ export function SearchDialog({
const [results, setResults] = useState<SearchResult[]>([]); const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const [partial, setPartial] = useState(false);
const debouncedQuery = useDebounce(query, 250); const debouncedQuery = useDebounce(query, 250);
const abortRef = useRef<AbortController | null>(null); const requestRef = useRef(new AdminSearchRequest());
const groupLabels: Record<string, string> = { const groupLabels: Record<string, string> = {
users: t("users"), users: t("users"),
@@ -66,19 +70,22 @@ export function SearchDialog({
guilds: t("guilds"), guilds: t("guilds"),
shop: t("shop"), shop: t("shop"),
rareValues: t("rareValues"), rareValues: t("rareValues"),
furniture: t("furniture"),
catalog: t("catalog"),
tickets: t("tickets"),
}; };
const fetchResults = useCallback(async (q: string) => { const fetchResults = useCallback(async (q: string) => {
abortRef.current?.abort(); requestRef.current.cancel();
setFailed(false); setFailed(false);
setPartial(false);
setResults([]); setResults([]);
if (q.length < 2) { if (!canSearchRecords(q)) {
setResults([]); setResults([]);
setLoading(false); setLoading(false);
return; return;
} }
const controller = new AbortController(); const controller = requestRef.current.start();
abortRef.current = controller;
setLoading(true); setLoading(true);
try { try {
const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, { 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"); if (!res.ok) throw new Error("Search request failed");
const data: SearchResponse = await res.json(); const data: SearchResponse = await res.json();
if (abortRef.current !== controller || controller.signal.aborted) return; if (!requestRef.current.accepts(controller)) return;
if (data.ok) setResults(data.results); if (data.ok) {
else setFailed(true); setResults(data.results);
setPartial(Boolean(data.partial));
} else setFailed(true);
} catch (error) { } catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return; if (error instanceof DOMException && error.name === "AbortError") return;
if (abortRef.current === controller) { if (requestRef.current.accepts(controller)) {
setResults([]); setResults([]);
setFailed(true); setFailed(true);
} }
} finally { } finally {
if (abortRef.current === controller) setLoading(false); if (requestRef.current.accepts(controller)) setLoading(false);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open || query !== debouncedQuery) return;
fetchResults(debouncedQuery); fetchResults(debouncedQuery);
return () => abortRef.current?.abort(); return () => requestRef.current.cancel();
}, [debouncedQuery, fetchResults, open]); }, [debouncedQuery, fetchResults, open, query]);
useEffect(() => { useEffect(() => {
return () => abortRef.current?.abort(); return () => requestRef.current.cancel();
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -121,8 +130,29 @@ export function SearchDialog({
return () => document.removeEventListener("keydown", handleKeyDown); 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) { function handleSelect(url: string) {
setOpen(false); handleOpenChange(false);
setQuery(""); setQuery("");
setResults([]); setResults([]);
router.push(url); router.push(url);
@@ -132,19 +162,18 @@ export function SearchDialog({
const pages = getPages(query.trim().toLocaleLowerCase()).slice(0, 8); const pages = getPages(query.trim().toLocaleLowerCase()).slice(0, 8);
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={handleOpenChange}>
<button <DialogTrigger
type="button" type="button"
onClick={() => setOpen(true)}
aria-label={t("placeholder")} 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" 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} /> <SearchIcon size={14} />
<span className="hidden sm:inline">{t("placeholder")}</span> <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"> <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> </kbd>
</button> </DialogTrigger>
<DialogContent className="sm:max-w-lg p-0 gap-0 overflow-hidden"> <DialogContent className="sm:max-w-lg p-0 gap-0 overflow-hidden">
<DialogTitle className="sr-only">{t("placeholder")}</DialogTitle> <DialogTitle className="sr-only">{t("placeholder")}</DialogTitle>
<DialogDescription className="sr-only"> <DialogDescription className="sr-only">
@@ -157,21 +186,33 @@ export function SearchDialog({
<CommandInput <CommandInput
placeholder={t("placeholder")} placeholder={t("placeholder")}
value={query} value={query}
onValueChange={setQuery} onValueChange={handleQueryChange}
aria-label={t("placeholder")}
maxLength={64}
className="h-12" className="h-12"
/> />
<CommandList className="max-h-[400px]"> <CommandList className="max-h-[400px]">
{loading && query.length >= 2 ? ( {loading && canSearchRecords(query) ? (
<div className="py-6 text-center text-sm text-muted-foreground"> <div className="py-6 text-center text-sm text-muted-foreground">
… …
</div> </div>
) : null} ) : null}
{!loading && !failed && ( {!loading && !failed && !partial && canSearchRecords(query) && (
<CommandEmpty>{t("noResults")}</CommandEmpty> <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"> <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> </p>
)} )}
{pages.length > 0 && ( {pages.length > 0 && (
@@ -200,7 +241,9 @@ export function SearchDialog({
onSelect={() => handleSelect(item.url)} onSelect={() => handleSelect(item.url)}
className="flex flex-col items-start gap-0.5 py-2" 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 ? ( {item.subtitle ? (
<span className="text-xs text-muted-foreground truncate w-full"> <span className="text-xs text-muted-foreground truncate w-full">
{item.subtitle} {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[]) { function groupResults(results: SearchResult[]) {
const grouped: Record<string, SearchResult[]> = {}; const grouped: Record<string, SearchResult[]> = {};
for (const r of results) { for (const r of results) {
+3
View File
@@ -54,6 +54,9 @@ vi.mock("@/lib/db", () => {
where() { where() {
return query; return query;
}, },
orderBy() {
return query;
},
async limit() { async limit() {
state.queried.push(table); state.queried.push(table);
return [{ id: 1, title: "Match", subtitle: "Data" }]; 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"); expect(detail).toContain("canLiftBan");
}); });
it("exposes sanction timeline on user show", () => { it("exposes operational overview on user show", () => {
expect( expect(
existsSync("src/app/admin/users/_components/user-sanction-timeline.tsx"), existsSync(
"src/app/admin/users/_components/user-operational-overview.tsx",
),
).toBe(true); ).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, true,
); );
expect( expect(
readFileSync("src/app/admin/users/show/[id]/page.tsx", "utf8"), readFileSync("src/app/admin/users/show/[id]/page.tsx", "utf8"),
).toContain("UserSanctionTimeline"); ).toContain("UserOperationalOverview");
}); });
it("applies CMS SQL from drizzle/migrations (not Prisma)", () => { 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})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "myDashboard": {
+88
View File
@@ -1232,6 +1232,84 @@
"save": "Запазете промените", "save": "Запазете промените",
"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": { "bans": {
@@ -3237,6 +3315,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "error": {
+88
View File
@@ -1232,6 +1232,84 @@
"save": "Uložte změny", "save": "Uložte změny",
"saving": "Ukládání…", "saving": "Ukládání…",
"cancel": "Zrušit" "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": { "bans": {
@@ -3237,6 +3315,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "error": {
+88
View File
@@ -1232,6 +1232,84 @@
"save": "Save changes", "save": "Save changes",
"saving": "Gemmer...", "saving": "Gemmer...",
"cancel": "Annuller" "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": { "bans": {
@@ -3237,6 +3315,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "error": {
+88
View File
@@ -1203,6 +1203,84 @@
"save": "Änderungen speichern", "save": "Änderungen speichern",
"saving": "Wird gespeichert…", "saving": "Wird gespeichert…",
"cancel": "Abbrechen" "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": { "bans": {
@@ -3208,6 +3286,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "radioRequests": {
+88
View File
@@ -1232,6 +1232,84 @@
"save": "Αποθήκευση αλλαγών", "save": "Αποθήκευση αλλαγών",
"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": { "bans": {
@@ -3237,6 +3315,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "error": {
+87 -2
View File
@@ -1566,6 +1566,84 @@
"save": "Save changes", "save": "Save changes",
"saving": "Saving…", "saving": "Saving…",
"cancel": "Cancel" "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": { "bans": {
@@ -3815,7 +3893,7 @@
}, },
"search": { "search": {
"pages": "Pages and tools", "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.", "loadError": "Records could not be loaded. You can still open the pages above.",
"placeholder": "Search pages, users, rooms…", "placeholder": "Search pages, users, rooms…",
"users": "Users", "users": "Users",
@@ -3824,7 +3902,14 @@
"guilds": "Groups", "guilds": "Groups",
"shop": "Shop", "shop": "Shop",
"rareValues": "Rare values", "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": { "table": {
"search": "Search…", "search": "Search…",
+88
View File
@@ -1203,6 +1203,84 @@
"save": "Guardar cambios", "save": "Guardar cambios",
"saving": "Guardando…", "saving": "Guardando…",
"cancel": "Cancelar" "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": { "bans": {
@@ -3208,6 +3286,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "radioRequests": {
+90
View File
@@ -281,6 +281,96 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "myDashboard": {
+88
View File
@@ -1203,6 +1203,84 @@
"save": "Enregistrer les modifications", "save": "Enregistrer les modifications",
"saving": "Enregistrement…", "saving": "Enregistrement…",
"cancel": "Annuler" "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": { "bans": {
@@ -3208,6 +3286,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "radioRequests": {
+88
View File
@@ -1232,6 +1232,84 @@
"save": "Spremite promjene", "save": "Spremite promjene",
"saving": "Spremanje...", "saving": "Spremanje...",
"cancel": "Odustani" "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": { "bans": {
@@ -3237,6 +3315,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "error": {
+88
View File
@@ -1232,6 +1232,84 @@
"save": "Módosítások mentése", "save": "Módosítások mentése",
"saving": "Mentés…", "saving": "Mentés…",
"cancel": "Mégse" "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": { "bans": {
@@ -3237,6 +3315,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "error": {
+129 -44
View File
@@ -21,8 +21,8 @@
"friends": "Amici", "friends": "Amici",
"messages": "Messaggi", "messages": "Messaggi",
"admin": "Admin", "admin": "Admin",
"openMenu": "Open menu", "openMenu": "Apri menu",
"closeMenu": "Close menu", "closeMenu": "Chiudi menu",
"drawBadge": "Crea distintivo", "drawBadge": "Crea distintivo",
"mod": "Mod" "mod": "Mod"
}, },
@@ -991,7 +991,7 @@
"economy": "Economia", "economy": "Economia",
"catalog": "Catalogo", "catalog": "Catalogo",
"shop": "Negozio", "shop": "Negozio",
"marketplace": "Marketplace", "marketplace": "Mercatino",
"transactions": "Transazioni", "transactions": "Transazioni",
"vouchers": "Buoni", "vouchers": "Buoni",
"subscriptions": "Abbonamenti", "subscriptions": "Abbonamenti",
@@ -1042,12 +1042,12 @@
"hubNewsMedia": "Notizie e media", "hubNewsMedia": "Notizie e media",
"hubSiteContent": "Contenuti sito", "hubSiteContent": "Contenuti sito",
"hubEngagement": "Community", "hubEngagement": "Community",
"hubUsers": "Users", "hubUsers": "Utenti",
"hubAccess": "Access & security", "hubAccess": "Accesso e sicurezza",
"hubCatalog": "Catalog & rares", "hubCatalog": "Catalogo e rari",
"hubShop": "Shop & payments", "hubShop": "Negozio e pagamenti",
"hubTools": "Tools", "hubTools": "Strumenti",
"hubObservability": "Observability", "hubObservability": "Monitoraggio e diagnostica",
"community": "Community", "community": "Community",
"monitoring": "Monitoraggio", "monitoring": "Monitoraggio",
"staffAccess": "Gradi e permessi", "staffAccess": "Gradi e permessi",
@@ -1057,7 +1057,7 @@
"tradeLog": "Log trade", "tradeLog": "Log trade",
"tools": "Strumenti", "tools": "Strumenti",
"guilds": "Gruppi", "guilds": "Gruppi",
"itemsBase": "Items base", "itemsBase": "Definizioni furni",
"studio": "Studio", "studio": "Studio",
"peopleSupport": "Utenti e assistenza", "peopleSupport": "Utenti e assistenza",
"catalogFurniture": "Catalogo e furni", "catalogFurniture": "Catalogo e furni",
@@ -1519,11 +1519,11 @@
"resetPassword": "Reimposta password", "resetPassword": "Reimposta password",
"deleteUser": "Elimina utente", "deleteUser": "Elimina utente",
"confirmDelete": "Sei sicuro di voler eliminare {username}? Questa azione è irreversibile.", "confirmDelete": "Sei sicuro di voler eliminare {username}? Questa azione è irreversibile.",
"tabInfo": "Account Info", "tabInfo": "Informazioni account",
"tabBadges": "Badges ({count})", "tabBadges": "Distintivi ({count})",
"tabRooms": "Rooms ({count})", "tabRooms": "Stanze ({count})",
"tabSanctions": "Sanctions ({count})", "tabSanctions": "Sanzioni ({count})",
"tabActivity": "Activity" "tabActivity": "Attività"
}, },
"editForm": { "editForm": {
"title": "Modifica utente: {username}", "title": "Modifica utente: {username}",
@@ -1541,6 +1541,84 @@
"save": "Salva modifiche", "save": "Salva modifiche",
"saving": "Salvataggio…", "saving": "Salvataggio…",
"cancel": "Annulla" "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": { "bans": {
@@ -1686,19 +1764,19 @@
"actionsTitle": "Azioni", "actionsTitle": "Azioni",
"requesterTitle": "Richiedente", "requesterTitle": "Richiedente",
"detailsTitle": "Dettagli", "detailsTitle": "Dettagli",
"rankLabel": "Rank {rank}", "rankLabel": "Grado {rank}",
"messageCountLabel": "Messaggi", "messageCountLabel": "Messaggi",
"success": { "success": {
"replied": "Risposta inviata.", "replied": "Risposta inviata.",
"closed": "Ticket chiuso.", "closed": "Ticket chiuso.",
"reopened": "Ticket riaperto.", "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.", "queueHint": "Questa coda e il help center pubblico. I ticket del supporto CMS stanno nella tab Supporto CMS.",
"queueOtherCta": "Apri supporto CMS", "queueOtherCta": "Apri supporto CMS",
"queueCounts": "Aperti qui: {here} · Supporto CMS: {other}", "queueCounts": "Aperti qui: {here} · Supporto CMS: {other}",
"liftBanSubmit": "Lift requester ban & close", "liftBanSubmit": "Revoca il ban del richiedente e chiudi",
"requesterBanned": "Requester banned" "requesterBanned": "Richiedente bannato"
}, },
"cfh": { "cfh": {
"title": "Call for Help", "title": "Call for Help",
@@ -3383,11 +3461,11 @@
"urlCopied": "URL copiato negli appunti" "urlCopied": "URL copiato negli appunti"
}, },
"hubs": { "hubs": {
"newsMedia": "News & media", "newsMedia": "Notizie e contenuti multimediali",
"newsMediaSubtitle": "Articoli, foto, banner, ads e media library", "newsMediaSubtitle": "Articoli, foto, banner, ads e media library",
"siteContent": "Contenuti sito", "siteContent": "Contenuti sito",
"siteContentSubtitle": "Navigazione, help, box, tag e prefissi", "siteContentSubtitle": "Navigazione, help, box, tag e prefissi",
"engagement": "Engagement", "engagement": "Attività della community",
"engagementSubtitle": "Eventi, sondaggi e ticket di supporto", "engagementSubtitle": "Eventi, sondaggi e ticket di supporto",
"users": "Utenti", "users": "Utenti",
"usersSubtitle": "Directory, online, candidature e team", "usersSubtitle": "Directory, online, candidature e team",
@@ -3411,10 +3489,10 @@
"articles": "Articoli", "articles": "Articoli",
"photos": "Foto", "photos": "Foto",
"banners": "Banner", "banners": "Banner",
"ads": "Ads", "ads": "Pubblicità",
"media": "Media", "media": "Media",
"navigator": "Navigator", "navigator": "Navigatore",
"help": "Help", "help": "Assistenza",
"boxes": "Box", "boxes": "Box",
"tags": "Tag", "tags": "Tag",
"prefixes": "Prefissi", "prefixes": "Prefissi",
@@ -3422,9 +3500,9 @@
"eventTypes": "Tipi evento", "eventTypes": "Tipi evento",
"polls": "Sondaggi", "polls": "Sondaggi",
"tickets": "Supporto CMS", "tickets": "Supporto CMS",
"helpTickets": "Help center", "helpTickets": "Centro assistenza",
"templates": "Template", "templates": "Template",
"directory": "Directory", "directory": "Elenco utenti",
"multiAccounts": "Multi-account", "multiAccounts": "Multi-account",
"online": "Online", "online": "Online",
"applications": "Candidature", "applications": "Candidature",
@@ -3433,26 +3511,26 @@
"ip": "IP", "ip": "IP",
"vpn": "VPN", "vpn": "VPN",
"permissions": "Permessi", "permissions": "Permessi",
"wordFilter": "Word filter", "wordFilter": "Filtro parole",
"overview": "Panoramica", "overview": "Panoramica",
"actions": "Azioni", "actions": "Azioni",
"cfh": "CFH", "cfh": "Richieste di moderazione",
"team": "Team", "team": "Team",
"catalogTab": "Catalogo", "catalogTab": "Catalogo",
"rares": "Rare", "rares": "Rare",
"badges": "Badge", "badges": "Badge",
"achievements": "Achievement", "achievements": "Achievement",
"sounds": "Suoni", "sounds": "Suoni",
"shopTab": "Shop", "shopTab": "Negozio",
"transactions": "Transazioni", "transactions": "Transazioni",
"vouchers": "Voucher", "vouchers": "Voucher",
"subscriptions": "Abbonamenti", "subscriptions": "Abbonamenti",
"calendar": "Calendario", "calendar": "Calendario",
"history": "Cronologia", "history": "Cronologia",
"moderationTab": "Moderazione", "moderationTab": "Moderazione",
"monitoring": "Monitoring", "monitoring": "Monitoraggio",
"settingsTab": "Impostazioni", "settingsTab": "Impostazioni",
"embed": "Embed", "embed": "Incorporamento",
"apiKeys": "API key", "apiKeys": "API key",
"points": "Punti", "points": "Punti",
"ranks": "Rank", "ranks": "Rank",
@@ -3461,37 +3539,37 @@
"theme": "Tema", "theme": "Tema",
"maintenance": "Manutenzione", "maintenance": "Manutenzione",
"favicon": "Favicon", "favicon": "Favicon",
"emulator": "Emulator", "emulator": "Emulatore",
"email": "Email", "email": "Email",
"housekeeping": "Archivio permessi", "housekeeping": "Archivio permessi",
"commando": "Commando", "commando": "Centro operativo",
"rooms": "Stanze", "rooms": "Stanze",
"import": "Import", "import": "Importazione",
"importOverview": "Panoramica", "importOverview": "Panoramica",
"importBadges": "Badge", "importBadges": "Badge",
"importFurni": "Furni", "importFurni": "Furni",
"importUpload": "Upload", "importUpload": "Caricamento",
"importClothing": "Abbigliamento", "importClothing": "Abbigliamento",
"importEffects": "Effetti", "importEffects": "Effetti",
"importPets": "Pet", "importPets": "Pet",
"importClone": "Clone", "importClone": "Clonazione",
"importSync": "Sync", "importSync": "Sincronizzazione",
"importRepairIcons": "Ripara icone", "importRepairIcons": "Ripara icone",
"importAudit": "Audit", "importAudit": "Verifica dati",
"translations": "Traduzioni", "translations": "Traduzioni",
"logs": "Log", "logs": "Log",
"audit": "Audit", "audit": "Registro modifiche",
"chat": "Chat", "chat": "Chat",
"commands": "Comandi", "commands": "Comandi",
"trades": "Trade", "trades": "Trade",
"analytics": "Analytics", "analytics": "Statistiche",
"activity": "Attività", "activity": "Attività",
"economy": "Economia", "economy": "Economia",
"devops": "DevOps", "devops": "DevOps",
"errors": "Errori", "errors": "Errori",
"alerts": "Alert", "alerts": "Alert",
"toolsGroup": "Strumenti", "toolsGroup": "Strumenti",
"ticketInbox": "Inbox", "ticketInbox": "Ticket ricevuti",
"cmsErrors": "Errori CMS", "cmsErrors": "Errori CMS",
"themeBuilder": "Editor del tema", "themeBuilder": "Editor del tema",
"studio": "Studio" "studio": "Studio"
@@ -3506,7 +3584,7 @@
"analyticsSubtitle": "Attività hotel, retention ed economia", "analyticsSubtitle": "Attività hotel, retention ed economia",
"devops": "DevOps", "devops": "DevOps",
"devopsSubtitle": "Salute servizi ed errori emulator", "devopsSubtitle": "Salute servizi ed errori emulator",
"import": "Import", "import": "Importazione",
"importSubtitle": "Furni, badge, abbigliamento e strumenti asset" "importSubtitle": "Furni, badge, abbigliamento e strumenti asset"
}, },
"settings": { "settings": {
@@ -3790,7 +3868,7 @@
}, },
"search": { "search": {
"pages": "Pagine e strumenti", "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.", "loadError": "Impossibile caricare i risultati. Puoi comunque aprire le pagine elencate.",
"placeholder": "Cerca pagine, utenti, stanze…", "placeholder": "Cerca pagine, utenti, stanze…",
"users": "Utenti", "users": "Utenti",
@@ -3799,7 +3877,14 @@
"guilds": "Gruppi", "guilds": "Gruppi",
"shop": "Negozio", "shop": "Negozio",
"rareValues": "Valori dei rari", "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": { "table": {
"search": "Cerca…", "search": "Cerca…",
+90
View File
@@ -281,6 +281,96 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "myDashboard": {
+185 -60
View File
@@ -5,7 +5,7 @@
"articles": "Artikelen", "articles": "Artikelen",
"photos": "Foto's", "photos": "Foto's",
"staff": "Team", "staff": "Team",
"rankings": "Rankings", "rankings": "Ranglijsten",
"guilds": "Gildes", "guilds": "Gildes",
"events": "Evenementen", "events": "Evenementen",
"polls": "Peilingen", "polls": "Peilingen",
@@ -20,7 +20,7 @@
"radio": "Radio", "radio": "Radio",
"friends": "Vrienden", "friends": "Vrienden",
"messages": "Berichten", "messages": "Berichten",
"admin": "Admin", "admin": "Beheer",
"openMenu": "Menu openen", "openMenu": "Menu openen",
"closeMenu": "Menu sluiten", "closeMenu": "Menu sluiten",
"drawBadge": "Badge tekenen", "drawBadge": "Badge tekenen",
@@ -987,26 +987,26 @@
"photos": "Foto's", "photos": "Foto's",
"tickets": "Supportbalie", "tickets": "Supportbalie",
"helpTickets": "Helpcentrum-tickets", "helpTickets": "Helpcentrum-tickets",
"events": "Events", "events": "Evenementen",
"polls": "Polls", "polls": "Polls",
"guilds": "Gildes", "guilds": "Gildes",
"banners": "Banners", "banners": "Banners",
"prefixes": "Prefixes", "prefixes": "Voorvoegsels",
"usersAndAccess": "Gebruikers & toegang", "usersAndAccess": "Gebruikers & toegang",
"users": "Gebruikers", "users": "Gebruikers",
"bans": "Bans", "bans": "Verbanningen",
"ipManagement": "IP-beheer", "ipManagement": "IP-beheer",
"vpn": "VPN", "vpn": "VPN",
"applications": "Sollicitaties", "applications": "Sollicitaties",
"teams": "Teams", "teams": "Teams",
"permissions": "Permissies", "permissions": "Permissies",
"onlineUsers": "Online users", "onlineUsers": "Online gebruikers",
"moderation": "Moderation", "moderation": "Moderatie",
"economy": "Economie", "economy": "Economie",
"catalog": "Catalogus", "catalog": "Catalogus",
"catalogMaintenance": "Catalogus-onderhoud", "catalogMaintenance": "Catalogus-onderhoud",
"shop": "Winkel", "shop": "Winkel",
"marketplace": "Marketplace", "marketplace": "Marktplaats",
"transactions": "Transacties", "transactions": "Transacties",
"vouchers": "Vouchers", "vouchers": "Vouchers",
"subscriptions": "Abonnementen", "subscriptions": "Abonnementen",
@@ -1017,11 +1017,11 @@
"radio": "Radio", "radio": "Radio",
"radioHistory": "Radio geschiedenis", "radioHistory": "Radio geschiedenis",
"radioBanners": "Banners", "radioBanners": "Banners",
"radioEmbed": "Embed", "radioEmbed": "Insluiten",
"radioApiKeys": "API-sleutels", "radioApiKeys": "API-sleutels",
"radioModeration": "Moderatie", "radioModeration": "Moderatie",
"radioSettings": "Instellingen", "radioSettings": "Instellingen",
"radioMonitoring": "Monitoring", "radioMonitoring": "Bewaking",
"radioPoints": "Punten", "radioPoints": "Punten",
"radioRanks": "Rangen", "radioRanks": "Rangen",
"radioAutodj": "Auto DJ", "radioAutodj": "Auto DJ",
@@ -1030,8 +1030,8 @@
"emulator": "Emulator", "emulator": "Emulator",
"alerts": "Meldingen", "alerts": "Meldingen",
"logs": "Logboeken", "logs": "Logboeken",
"auditLog": "Audit log", "auditLog": "Wijzigingslogboek",
"analytics": "Analytics", "analytics": "Statistieken",
"devops": "DevOps", "devops": "DevOps",
"wordFilter": "Woordfilter", "wordFilter": "Woordfilter",
"maintenance": "Onderhoud", "maintenance": "Onderhoud",
@@ -1053,26 +1053,44 @@
"housekeeping": "Permissie-archief", "housekeeping": "Permissie-archief",
"hotelManagementPanel": "Hotelbeheerpaneel", "hotelManagementPanel": "Hotelbeheerpaneel",
"rankLabel": "Rang {rank}", "rankLabel": "Rang {rank}",
"hubNewsMedia": "News & media", "hubNewsMedia": "Nieuws en media",
"hubSiteContent": "Site content", "hubSiteContent": "Website-inhoud",
"hubEngagement": "Engagement", "hubEngagement": "Communityactiviteiten",
"hubUsers": "Users", "hubUsers": "Gebruikers",
"hubAccess": "Access & security", "hubAccess": "Toegang en beveiliging",
"hubCatalog": "Catalog & rares", "hubCatalog": "Catalogus en zeldzame meubels",
"hubShop": "Shop & payments", "hubShop": "Winkel en betalingen",
"hubTools": "Tools", "hubTools": "Hulpmiddelen",
"hubObservability": "Observability", "hubObservability": "Bewaking en diagnostiek",
"community": "Community", "community": "Community",
"monitoring": "Monitoring", "monitoring": "Bewaking",
"staffAccess": "Rangen & rechten", "staffAccess": "Rangen & rechten",
"multiAccounts": "Multi-accounts", "multiAccounts": "Gekoppelde accounts",
"chatLog": "Chatlog", "chatLog": "Chatlog",
"commandLog": "Commandolog", "commandLog": "Commandolog",
"tradeLog": "Tradelog", "tradeLog": "Tradelog",
"tools": "Tools", "tools": "Hulpmiddelen",
"itemsBase": "Items base", "itemsBase": "Meubeldefinities",
"adminMenu": "Adminmenu", "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": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -1153,7 +1171,12 @@
"restoreRevision": "Herstellen in editor", "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.", "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.", "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.", "listActionError": "De actie voor dit artikel kon niet worden voltooid.",
"listStatus": "Publicatiestatus", "listStatus": "Publicatiestatus",
@@ -1172,7 +1195,11 @@
"listPagination": "Artikelpagina’s", "listPagination": "Artikelpagina’s",
"listPrevious": "Vorige", "listPrevious": "Vorige",
"listNext": "Volgende", "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": { "events": {
"newEvent": "+ Nieuw evenement", "newEvent": "+ Nieuw evenement",
@@ -1472,7 +1499,7 @@
"registered": "Geregistreerd", "registered": "Geregistreerd",
"lastOnline": "Laatst online", "lastOnline": "Laatst online",
"ipAddress": "IP-adres", "ipAddress": "IP-adres",
"machineId": "Machine ID", "machineId": "Apparaat-ID",
"sendAlert": "Melding sturen", "sendAlert": "Melding sturen",
"sendMessage": "Bericht sturen", "sendMessage": "Bericht sturen",
"disconnectUser": "Verbinding verbreken", "disconnectUser": "Verbinding verbreken",
@@ -1480,11 +1507,11 @@
"resetPassword": "Wachtwoord resetten", "resetPassword": "Wachtwoord resetten",
"deleteUser": "Gebruiker verwijderen", "deleteUser": "Gebruiker verwijderen",
"confirmDelete": "Weet je zeker dat je {username} wilt verwijderen? Dit kan niet ongedaan worden gemaakt.", "confirmDelete": "Weet je zeker dat je {username} wilt verwijderen? Dit kan niet ongedaan worden gemaakt.",
"tabInfo": "Account Info", "tabInfo": "Accountgegevens",
"tabBadges": "Badges ({count})", "tabBadges": "Badges ({count})",
"tabRooms": "Rooms ({count})", "tabRooms": "Kamers ({count})",
"tabSanctions": "Sanctions ({count})", "tabSanctions": "Sancties ({count})",
"tabActivity": "Activity" "tabActivity": "Activiteit"
}, },
"editForm": { "editForm": {
"title": "Gebruiker bewerken: {username}", "title": "Gebruiker bewerken: {username}",
@@ -1502,6 +1529,84 @@
"save": "Wijzigingen opslaan", "save": "Wijzigingen opslaan",
"saving": "Opslaan…", "saving": "Opslaan…",
"cancel": "Annuleren" "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": { "bans": {
@@ -3123,7 +3228,7 @@
"radioSubtitle": "Stream, DJ-tools, embed en monitoring", "radioSubtitle": "Stream, DJ-tools, embed en monitoring",
"settings": "Instellingen", "settings": "Instellingen",
"settingsSubtitle": "CMS-configuratie, thema, onderhoud en gerelateerde tools", "settingsSubtitle": "CMS-configuratie, thema, onderhoud en gerelateerde tools",
"tools": "Tools", "tools": "Hulpmiddelen",
"toolsSubtitle": "RCON, kamers, import en vertalingen", "toolsSubtitle": "RCON, kamers, import en vertalingen",
"observability": "Observeerbaarheid", "observability": "Observeerbaarheid",
"observabilitySubtitle": "Logs, analytics, DevOps en hotelmeldingen", "observabilitySubtitle": "Logs, analytics, DevOps en hotelmeldingen",
@@ -3134,10 +3239,10 @@
"ads": "Advertenties", "ads": "Advertenties",
"media": "Media", "media": "Media",
"navigator": "Navigator", "navigator": "Navigator",
"help": "Help", "help": "Ondersteuning",
"boxes": "Boxen", "boxes": "Boxen",
"tags": "Tags", "tags": "Tags",
"prefixes": "Prefixes", "prefixes": "Voorvoegsels",
"events": "Evenementen", "events": "Evenementen",
"eventTypes": "Evenementtypen", "eventTypes": "Evenementtypen",
"polls": "Polls", "polls": "Polls",
@@ -3145,21 +3250,21 @@
"helpTickets": "Helpcentrum", "helpTickets": "Helpcentrum",
"templates": "Sjablonen", "templates": "Sjablonen",
"directory": "Adresboek", "directory": "Adresboek",
"multiAccounts": "Multi-accounts", "multiAccounts": "Gekoppelde accounts",
"online": "Online", "online": "Online",
"applications": "Sollicitaties", "applications": "Sollicitaties",
"teams": "Teams", "teams": "Teams",
"bans": "Bans", "bans": "Verbanningen",
"ip": "IP", "ip": "IP",
"vpn": "VPN", "vpn": "VPN",
"permissions": "Permissies", "permissions": "Permissies",
"wordFilter": "Woordfilter", "wordFilter": "Woordfilter",
"overview": "Overzicht", "overview": "Overzicht",
"actions": "Acties", "actions": "Acties",
"cfh": "CFH", "cfh": "Moderatieverzoeken",
"team": "Team", "team": "Team",
"catalogTab": "Catalogus", "catalogTab": "Catalogus",
"rares": "Rares", "rares": "Zeldzame meubels",
"badges": "Badges", "badges": "Badges",
"achievements": "Prestaties", "achievements": "Prestaties",
"sounds": "Geluiden", "sounds": "Geluiden",
@@ -3170,9 +3275,9 @@
"calendar": "Kalender", "calendar": "Kalender",
"history": "Geschiedenis", "history": "Geschiedenis",
"moderationTab": "Moderatie", "moderationTab": "Moderatie",
"monitoring": "Monitoring", "monitoring": "Bewaking",
"settingsTab": "Instellingen", "settingsTab": "Instellingen",
"embed": "Embed", "embed": "Insluiten",
"apiKeys": "API-sleutels", "apiKeys": "API-sleutels",
"points": "Punten", "points": "Punten",
"ranks": "Rangen", "ranks": "Rangen",
@@ -3185,35 +3290,36 @@
"emulator": "Emulator", "emulator": "Emulator",
"email": "E-mail", "email": "E-mail",
"housekeeping": "Permissie-archief", "housekeeping": "Permissie-archief",
"commando": "Commando", "commando": "Operationeel centrum",
"rooms": "Kamers", "rooms": "Kamers",
"import": "Import", "import": "Importeren",
"studio": "Studio", "studio": "Studio",
"importOverview": "Overzicht", "importOverview": "Overzicht",
"importBadges": "Badges", "importBadges": "Badges",
"importFurni": "Meubels", "importFurni": "Meubels",
"importUpload": "Upload", "importUpload": "Uploaden",
"importClothing": "Kleding", "importClothing": "Kleding",
"importEffects": "Effecten", "importEffects": "Effecten",
"importPets": "Huisdieren", "importPets": "Huisdieren",
"importClone": "Kloon", "importClone": "Kloon",
"importSync": "Sync", "importSync": "Synchronisatie",
"importRepairIcons": "Pictogrammen repareren", "importRepairIcons": "Pictogrammen repareren",
"importAudit": "Audit", "importAudit": "Gegevenscontrole",
"translations": "Vertalingen", "translations": "Vertalingen",
"logs": "Logs", "logs": "Logboeken",
"audit": "Audit", "audit": "Wijzigingslogboek",
"chat": "Chat", "chat": "Chat",
"commands": "Commands", "commands": "Commando's",
"trades": "Trades", "trades": "Ruiltransacties",
"analytics": "Analytics", "analytics": "Statistieken",
"activity": "Activiteit", "activity": "Activiteit",
"economy": "Economie", "economy": "Economie",
"devops": "DevOps", "devops": "DevOps",
"errors": "Fouten", "errors": "Fouten",
"alerts": "Meldingen", "alerts": "Meldingen",
"toolsGroup": "Tools", "toolsGroup": "Hulpmiddelen",
"ticketInbox": "Inbox" "ticketInbox": "Ontvangen tickets",
"cmsErrors": "CMS-fouten"
}, },
"events": "Evenementen", "events": "Evenementen",
"eventsSubtitle": "Hotel-evenementen en evenementtypes", "eventsSubtitle": "Hotel-evenementen en evenementtypes",
@@ -3221,11 +3327,11 @@
"ticketsSubtitle": "Twee aparte wachtrijen: CMS support desk en publieke helpcenter-tickets", "ticketsSubtitle": "Twee aparte wachtrijen: CMS support desk en publieke helpcenter-tickets",
"staffAccess": "Rangen & rechten", "staffAccess": "Rangen & rechten",
"staffAccessSubtitle": "Staffrangen en live toegang. Archief is alleen vergelijken/exporteren.", "staffAccessSubtitle": "Staffrangen en live toegang. Archief is alleen vergelijken/exporteren.",
"analytics": "Analytics", "analytics": "Statistieken",
"analyticsSubtitle": "Hotelactiviteit, retentie en economie", "analyticsSubtitle": "Hotelactiviteit, retentie en economie",
"devops": "DevOps", "devops": "DevOps",
"devopsSubtitle": "Servicegezondheid en emulatorfouten", "devopsSubtitle": "Servicegezondheid en emulatorfouten",
"import": "Import", "import": "Importeren",
"importSubtitle": "Meubels, badges, kleding en asset-tools" "importSubtitle": "Meubels, badges, kleding en asset-tools"
}, },
"tickets": { "tickets": {
@@ -3275,19 +3381,19 @@
"actionsTitle": "Acties", "actionsTitle": "Acties",
"requesterTitle": "Aanvrager", "requesterTitle": "Aanvrager",
"detailsTitle": "Details", "detailsTitle": "Details",
"rankLabel": "Rank {rank}", "rankLabel": "Rang {rank}",
"messageCountLabel": "Berichten", "messageCountLabel": "Berichten",
"success": { "success": {
"replied": "Antwoord verzonden.", "replied": "Antwoord verzonden.",
"closed": "Ticket gesloten.", "closed": "Ticket gesloten.",
"reopened": "Ticket heropend.", "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.", "queueHint": "Deze wachtrij is het publieke helpcenter. CMS support desk tickets staan onder Supportbalie.",
"queueOtherCta": "Open CMS support desk", "queueOtherCta": "Open CMS support desk",
"queueCounts": "Open hier: {here} · Supportbalie: {other}", "queueCounts": "Open hier: {here} · Supportbalie: {other}",
"liftBanSubmit": "Lift requester ban & close", "liftBanSubmit": "Verbanning van aanvrager opheffen en sluiten",
"requesterBanned": "Requester banned" "requesterBanned": "Aanvrager is verbannen"
}, },
"cfh": { "cfh": {
"title": "Call for Help", "title": "Call for Help",
@@ -3583,6 +3689,25 @@
"specialPricing": "Speciale prijs: {credits} credits, {points} punten (type {type})", "specialPricing": "Speciale prijs: {credits} credits, {points} punten (type {type})",
"restrictedOffers": "{count} aanbiedingen in deze categorie vereisen een Club-lidmaatschap.", "restrictedOffers": "{count} aanbiedingen in deze categorie vereisen een Club-lidmaatschap.",
"clubOffer": "Club-aanbieding" "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": { "mod": {
+88
View File
@@ -1232,6 +1232,84 @@
"save": "Lagre endringer", "save": "Lagre endringer",
"saving": "Lagrer …", "saving": "Lagrer …",
"cancel": "Avbryt" "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": { "bans": {
@@ -3237,6 +3315,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "error": {
+88
View File
@@ -1233,6 +1233,84 @@
"save": "Zapisz zmiany", "save": "Zapisz zmiany",
"saving": "Zapisuję…", "saving": "Zapisuję…",
"cancel": "Anuluj" "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": { "bans": {
@@ -3238,6 +3316,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "error": {
+88
View File
@@ -1233,6 +1233,84 @@
"save": "Salvar alterações", "save": "Salvar alterações",
"saving": "Salvando…", "saving": "Salvando…",
"cancel": "Cancelar" "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": { "bans": {
@@ -3238,6 +3316,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "error": {
+88
View File
@@ -1232,6 +1232,84 @@
"save": "Salvați modificările", "save": "Salvați modificările",
"saving": "Se salvează…", "saving": "Se salvează…",
"cancel": "Anulează" "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": { "bans": {
@@ -3237,6 +3315,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "error": {
+88
View File
@@ -1231,6 +1231,84 @@
"save": "Save changes", "save": "Save changes",
"saving": "Saving…", "saving": "Saving…",
"cancel": "Cancel" "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": { "bans": {
@@ -3236,6 +3314,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "error": {
+88
View File
@@ -1232,6 +1232,84 @@
"save": "Uložiť zmeny", "save": "Uložiť zmeny",
"saving": "Ukladá sa…", "saving": "Ukladá sa…",
"cancel": "Zrušiť" "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": { "bans": {
@@ -3237,6 +3315,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "error": {
+88
View File
@@ -1232,6 +1232,84 @@
"save": "Сачувај промене", "save": "Сачувај промене",
"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": { "bans": {
@@ -3237,6 +3315,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "error": {
+88
View File
@@ -1232,6 +1232,84 @@
"save": "Spara ändringar", "save": "Spara ändringar",
"saving": "Sparar...", "saving": "Sparar...",
"cancel": "Avbryt" "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": { "bans": {
@@ -3237,6 +3315,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "error": {
+88
View File
@@ -1233,6 +1233,84 @@
"save": "Değişiklikleri kaydet", "save": "Değişiklikleri kaydet",
"saving": "Kaydediliyor…", "saving": "Kaydediliyor…",
"cancel": "İptal" "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": { "bans": {
@@ -3238,6 +3316,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "error": {
+88
View File
@@ -1232,6 +1232,84 @@
"save": "Зберегти зміни", "save": "Зберегти зміни",
"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": { "bans": {
@@ -3237,6 +3315,16 @@
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.", "restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer" "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": { "error": {