From 89526af344c3ef78e63badac59d3a155fe34bdbd Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 9 Sep 2026 21:14:10 +0200 Subject: [PATCH] feat(hk): expand search and add operational user overview --- docs/cms-upgrade-2026-09.md | 20 + .../users/_components/create-user-dialog.tsx | 57 +-- .../users/_components/user-account-info.tsx | 28 +- .../_components/user-badges-readonly.tsx | 4 +- src/app/admin/users/_components/user-hero.tsx | 68 ++- .../_components/user-operational-overview.tsx | 123 +++++ .../users/_lib/load-user-overview.test.ts | 95 ++++ .../admin/users/_lib/load-user-overview.ts | 137 ++++++ src/app/admin/users/_lib/load-user.test.ts | 87 ++++ src/app/admin/users/_lib/load-user.ts | 97 ++-- src/app/admin/users/edit/[id]/page.tsx | 86 ++-- src/app/admin/users/show/[id]/page.tsx | 64 ++- src/app/api/admin/search/route.test.ts | 247 ++++++++++ src/app/api/admin/search/route.ts | 439 +++++++++++++----- src/components/admin/search-dialog.tsx | 100 ++-- src/lib/admin-search-permissions.test.ts | 3 + src/lib/admin-search-request.test.ts | 22 + src/lib/admin-search-request.ts | 20 + src/lib/staff-smoke-contract.test.ts | 10 +- src/messages/ar.json | 90 ++++ src/messages/bg.json | 88 ++++ src/messages/cs.json | 88 ++++ src/messages/da.json | 88 ++++ src/messages/de.json | 88 ++++ src/messages/el.json | 88 ++++ src/messages/en.json | 89 +++- src/messages/es.json | 88 ++++ src/messages/fi.json | 90 ++++ src/messages/fr.json | 88 ++++ src/messages/hr.json | 88 ++++ src/messages/hu.json | 88 ++++ src/messages/it.json | 173 +++++-- src/messages/ja.json | 90 ++++ src/messages/nl.json | 245 +++++++--- src/messages/no.json | 88 ++++ src/messages/pl.json | 88 ++++ src/messages/pt.json | 88 ++++ src/messages/ro.json | 88 ++++ src/messages/ru.json | 88 ++++ src/messages/sk.json | 88 ++++ src/messages/sr.json | 88 ++++ src/messages/sv.json | 88 ++++ src/messages/tr.json | 88 ++++ src/messages/uk.json | 88 ++++ 44 files changed, 3740 insertions(+), 416 deletions(-) create mode 100644 src/app/admin/users/_components/user-operational-overview.tsx create mode 100644 src/app/admin/users/_lib/load-user-overview.test.ts create mode 100644 src/app/admin/users/_lib/load-user-overview.ts create mode 100644 src/app/admin/users/_lib/load-user.test.ts create mode 100644 src/app/api/admin/search/route.test.ts create mode 100644 src/lib/admin-search-request.test.ts create mode 100644 src/lib/admin-search-request.ts diff --git a/docs/cms-upgrade-2026-09.md b/docs/cms-upgrade-2026-09.md index 7491accc..4b9d0b4c 100644 --- a/docs/cms-upgrade-2026-09.md +++ b/docs/cms-upgrade-2026-09.md @@ -81,3 +81,23 @@ Validation: 1,506 tests passed (six skipped), type checking, lint, translation contracts and a production build with fixture configuration. A browser fixture verified the real dialog at 1280 and 390 pixels; server actions were simulated. The new database migration and package publication have not run in production. + +## Housekeeping search and user overview + +The existing global search now includes furniture, normal/Club catalog categories +and both ticket sources. Results respect module permissions, accept single-digit +IDs, and remain usable when one source fails. Keyboard navigation and cancellation +prevent stale search responses from replacing newer results. + +User details open on an operational overview with up to five records from each +authorized source: bans, active mute, support tickets, help tickets, reports, +payments, catalog purchases and audit activity. Failed sources are distinguished +from empty results. User detail and edit pages also apply log permissions before +loading activity and exposing counters. + +Italian and Dutch navigation, user management, news and support labels were +reviewed. The new search and overview copy has English, Italian and Dutch text; +other locales receive English fallback strings. This is a focused editorial pass, +not a full translation of every CMS page. No database migration or dependency +change is required. Browser checks use real components with simulated data at +1280 and 390 pixels; production database behavior still needs deployment validation. diff --git a/src/app/admin/users/_components/create-user-dialog.tsx b/src/app/admin/users/_components/create-user-dialog.tsx index ddd8a76d..64dc5050 100644 --- a/src/app/admin/users/_components/create-user-dialog.tsx +++ b/src/app/admin/users/_components/create-user-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { Loader2, UserPlus } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import { toast } from "sonner"; import { createUser } from "@/actions/users"; @@ -28,17 +29,10 @@ interface CreateUserDialogProps { sessionRank: number; } -const RANK_OPTIONS: { value: number; label: string }[] = [ - { value: 1, label: "User (1)" }, - { value: 2, label: "Bronze (2)" }, - { value: 3, label: "Silver (3)" }, - { value: 4, label: "Gold (4)" }, - { value: 5, label: "Moderator (5)" }, - { value: 6, label: "Admin (6)" }, - { value: 7, label: "Super Admin (7)" }, -]; +const RANK_OPTIONS = [1, 2, 3, 4, 5, 6, 7]; export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) { + const t = useTranslations("pages.admin.users.create"); const { run, isPending } = useServerAction(); const [open, setOpen] = useState(false); const [username, setUsername] = useState(""); @@ -48,7 +42,7 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) { const [motto, setMotto] = useState(""); const allowedRanks = RANK_OPTIONS.filter((r) => - sessionRank >= 7 ? true : r.value < sessionRank, + sessionRank >= 7 ? true : r < sessionRank, ); function reset() { @@ -67,22 +61,22 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) { for (let i = 0; i < buf.length; i++) out += charset[buf[i] % charset.length]; setPassword(out); - toast.message("Password generated", { description: out }); + toast.message(t("passwordGenerated")); } function handleCreate() { const u = username.trim(); const m = mail.trim(); if (!u || !m || !password) { - toast.error("Username, email and password are required"); + toast.error(t("required")); return; } if (u.length < 3 || u.length > 20 || !/^[a-zA-Z0-9_-]+$/.test(u)) { - toast.error("Invalid username (3-20 chars, letters/digits/_/-)"); + toast.error(t("invalidUsername")); return; } if (password.length < 6) { - toast.error("Password must be at least 6 characters"); + toast.error(t("passwordLength")); return; } @@ -96,7 +90,7 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) { motto: motto.trim(), }), { - successMessage: `User "${u}" created.`, + successMessage: t("created", { username: u }), onSuccess: () => { setOpen(false); reset(); @@ -116,31 +110,28 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) { - Create User - - Account is created immediately with default look and 5,000 - credits/duckets/diamonds. - + {t("title")} + {t("description")}
- + setUsername(e.target.value)} - placeholder="HabboName" + placeholder={t("usernamePlaceholder")} autoComplete="off" maxLength={20} />
- +
- +
setPassword(e.target.value)} - placeholder="Minimum 6 characters" + placeholder={t("passwordPlaceholder")} autoComplete="new-password" className="font-mono" /> @@ -167,33 +158,33 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) { variant="outline" onClick={generatePassword} > - Generate + {t("generate")}
- +
- + setMotto(e.target.value)} maxLength={127} - placeholder="I'm new here!" + placeholder={t("mottoPlaceholder")} />
@@ -207,7 +198,7 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) { ) : ( )} - Create User + {t("title")}
diff --git a/src/app/admin/users/_components/user-account-info.tsx b/src/app/admin/users/_components/user-account-info.tsx index 27480638..ef639356 100644 --- a/src/app/admin/users/_components/user-account-info.tsx +++ b/src/app/admin/users/_components/user-account-info.tsx @@ -6,6 +6,7 @@ import { MonitorSmartphone, Shield, } from "lucide-react"; +import { useTranslations } from "next-intl"; import Link from "@/components/link"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -25,40 +26,41 @@ interface UserAccountInfoProps { } export function UserAccountInfo({ user }: UserAccountInfoProps) { + const t = useTranslations("pages.admin.users.presentation"); return ( - Account Details + {t("accountDetails")}
- Registered + {t("registered")} {formatDate(new Date(user.accountCreated * 1000), "date")}
- Last Login + {t("lastLogin")} {user.lastLogin ? formatDate(new Date(user.lastLogin * 1000)) - : "Never"} + : t("never")}
- IP Register + {t("ipRegister")} {user.ipRegister} @@ -66,14 +68,14 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
- IP Current + {t("ipCurrent")} {user.ipCurrent} @@ -88,22 +90,22 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) { variant={user.twoFactorConfirmed ? "default" : "outline"} className="ml-auto" > - {user.twoFactorConfirmed ? "Enabled" : "Disabled"} + {user.twoFactorConfirmed ? t("enabled") : t("disabled")}
- Machine ID + {t("machineId")} - {user.machine_id || "N/A"} + {user.machine_id || t("unavailable")}
- Email + {t("email")} {user.mail}
- Gender + {t("gender")} {user.gender} diff --git a/src/app/admin/users/_components/user-badges-readonly.tsx b/src/app/admin/users/_components/user-badges-readonly.tsx index 50886d1b..a22d38b9 100644 --- a/src/app/admin/users/_components/user-badges-readonly.tsx +++ b/src/app/admin/users/_components/user-badges-readonly.tsx @@ -1,4 +1,5 @@ "use client"; +import { useTranslations } from "next-intl"; interface Badge { id: number; @@ -6,8 +7,9 @@ interface Badge { } export function UserBadgesReadonly({ badges }: { badges: Badge[] }) { + const t = useTranslations("pages.admin.users.presentation"); if (badges.length === 0) { - return

No badges

; + return

{t("noBadges")}

; } return ( diff --git a/src/app/admin/users/_components/user-hero.tsx b/src/app/admin/users/_components/user-hero.tsx index 80178dbb..d809f2b2 100644 --- a/src/app/admin/users/_components/user-hero.tsx +++ b/src/app/admin/users/_components/user-hero.tsx @@ -10,6 +10,7 @@ import { WifiOff, } from "lucide-react"; import { useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb"; import { UserWatchToggle } from "@/components/admin/user-watch-toggle"; import Link from "@/components/link"; @@ -42,6 +43,7 @@ interface UserHeroProps { activeBan: boolean; mode: "show" | "edit"; canEdit: boolean; + canViewLogs?: boolean; /** When non-null, render the watch toggle with this initial state. */ watching?: boolean | null; } @@ -58,7 +60,9 @@ export function UserHero({ mode, canEdit, watching, + canViewLogs = true, }: UserHeroProps) { + const t = useTranslations("pages.admin.users.presentation"); const params = useSearchParams(); const list = params.get("list") ?? ""; const listHref = userListHref(list); @@ -66,9 +70,9 @@ export function UserHero({ <> @@ -107,14 +111,14 @@ export function UserHero({ {mode === "show" && canEdit && ( )} {mode === "edit" && ( )} @@ -134,22 +138,24 @@ export function UserHero({ )}
- {user.permission.rankName || `Rank ${user.rank}`} + + {user.permission.rankName || t("rank", { rank: user.rank })} + {isOnline ? ( <> - Online + {t("online")} ) : ( <> - Offline + {t("offline")} )} - {activeBan && Banned} + {activeBan && {t("banned")}} {user.twoFactorConfirmed ? ( {formatNumber(user.credits)} - credits + + {t("credits")} +
{formatNumber(diamonds)} - diamonds + + {t("diamonds")} +
{formatNumber(duckets)} - duckets + + {t("duckets")} +
{formatNumber(user.pixels)} - pixels + + {t("pixels")} +
@@ -197,18 +211,26 @@ export function UserHero({ >

{roomCount}

-

Rooms

+

{t("rooms")}

-
- -

{formatNumber(chatCount)}

-

Messages

-
-
- -

{tradeCount}

-

Trades

-
+ {canViewLogs && ( +
+ +

{formatNumber(chatCount)}

+

+ {t("messages")} +

+
+ )} + {canViewLogs && ( +
+ +

{tradeCount}

+

+ {t("trades")} +

+
+ )}
diff --git a/src/app/admin/users/_components/user-operational-overview.tsx b/src/app/admin/users/_components/user-operational-overview.tsx new file mode 100644 index 00000000..caa86803 --- /dev/null +++ b/src/app/admin/users/_components/user-operational-overview.tsx @@ -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 ( +
+

{t("hint")}

+ {sections.length === 0 && ( +

+ {t("restricted")} +

+ )} +
+ {sections.map((section) => ( +
+
+

{t(section.key)}

+ {section.href && ( + + {t("openModule")} + + )} +
+ {section.key === "purchases" && ( +

+ {t("purchaseHint")} +

+ )} + {section.key === "activity" && ( +

+ {t("activityHint")} +

+ )} + {section.state === "error" ? ( +

+ {t("error")} +

+ ) : section.rows.length === 0 ? ( +

{t("empty")}

+ ) : ( +
    + {section.rows.map((row) => ( +
  • +
    + {row.href ? ( + + #{String(row.id)} · {row.title || "—"} + + ) : ( + + #{String(row.id)} · {row.title || "—"} + + )} + {row.status && ( + + {[ + "active", + "expired", + "open", + "closed", + "reported", + "reporter", + ].includes(row.status) + ? t(`status.${row.status}`) + : row.status} + + )} +
    + {section.key === "purchases" && ( +

    + {t("purchaseDetail", { + amount: row.amount ?? "—", + credits: row.credits ?? "—", + points: row.points ?? "—", + type: row.pointsType ?? "—", + })} +

    + )} + {row.detail && ( +

    + {row.detail} +

    + )} +

    + {section.key === "mute" + ? t("muteUntil", { date: eventDate(row.at) }) + : eventDate(row.at)} +

    +
  • + ))} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/src/app/admin/users/_lib/load-user-overview.test.ts b/src/app/admin/users/_lib/load-user-overview.test.ts new file mode 100644 index 00000000..231470ad --- /dev/null +++ b/src/app/admin/users/_lib/load-user-overview.test.ts @@ -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(), + 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); +}); diff --git a/src/app/admin/users/_lib/load-user-overview.ts b/src/app/admin/users/_lib/load-user-overview.ts new file mode 100644 index 00000000..09d18a58 --- /dev/null +++ b/src/app/admin/users/_lib/load-user-overview.ts @@ -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 { + const read = async (query: SQL): Promise => { + const [rows] = await db.execute(query); + return rows as unknown as OverviewRow[]; + }; + const sources: { + key: string; + permission: string; + href?: string; + query: () => Promise; + 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: [], + }; + } + }), + ); +} diff --git a/src/app/admin/users/_lib/load-user.test.ts b/src/app/admin/users/_lib/load-user.test.ts new file mode 100644 index 00000000..2383208f --- /dev/null +++ b/src/app/admin/users/_lib/load-user.test.ts @@ -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[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); +}); diff --git a/src/app/admin/users/_lib/load-user.ts b/src/app/admin/users/_lib/load-user.ts index 30021d6a..c85a7dfa 100644 --- a/src/app/admin/users/_lib/load-user.ts +++ b/src/app/admin/users/_lib/load-user.ts @@ -14,7 +14,10 @@ async function rawRows(query: ReturnType): Promise { return rows ?? []; } -export async function loadUserById(id: string) { +export async function loadUserById( + id: string, + access: { logs: boolean; bans: boolean }, +) { const numericId = Number(id); const isNumeric = Number.isSafeInteger(numericId) && @@ -44,6 +47,7 @@ export async function loadUserById(id: string) { .limit(1); if (!user) notFound(); + let activityUnavailable = false; const [ currencies, badges, @@ -64,30 +68,40 @@ export async function loadUserById(id: string) { .from(UsersBadges) .where(eq(UsersBadges.userId, user.id)) .orderBy(asc(UsersBadges.slotId)), - rawRows<{ - id: number; - banReason: string; - type: string; - timestamp: number; - banExpire: number; - staffName: string; - }>(sql` + access.bans + ? rawRows<{ + id: number; + banReason: string; + type: string; + timestamp: number; + banExpire: number; + staffName: string; + }>(sql` SELECT b.id, b.ban_reason AS banReason, b.type, b.timestamp, b.ban_expire AS banExpire, COALESCE(s.username, 'Unknown') AS staffName FROM bans b LEFT JOIN users s ON s.id = b.user_staff_id - WHERE b.user_id = ${user.id} ORDER BY b.id DESC LIMIT 5`), + WHERE b.user_id = ${user.id} ORDER BY b.id DESC LIMIT 5`).catch( + () => [], + ) + : [], rawRows<{ rankName: string }>(sql` SELECT COALESCE(rank_name, CONCAT('Rank ', id)) AS rankName FROM permissions WHERE id = ${user.rank} LIMIT 1`), db.select({ total: count() }).from(Rooms).where(eq(Rooms.ownerId, user.id)), - db - .select({ total: count() }) - .from(RoomTradeLog) - .where( - or( - eq(RoomTradeLog.userOneId, user.id), - eq(RoomTradeLog.userTwoId, user.id), - ), - ), + access.logs + ? db + .select({ total: count() }) + .from(RoomTradeLog) + .where( + or( + eq(RoomTradeLog.userOneId, user.id), + eq(RoomTradeLog.userTwoId, user.id), + ), + ) + .catch(() => { + activityUnavailable = true; + return []; + }) + : [], db .select({ id: Rooms.id, @@ -100,23 +114,37 @@ export async function loadUserById(id: string) { .where(eq(Rooms.ownerId, user.id)) .orderBy(desc(Rooms.users)) .limit(20), - rawRows<{ - id: number; - roomId: number; - timestamp: number; - message: string; - }>(sql` + access.logs + ? rawRows<{ + id: number; + roomId: number; + timestamp: number; + message: string; + }>(sql` SELECT ROW_NUMBER() OVER (ORDER BY timestamp DESC) AS id, room_id AS roomId, timestamp, message - FROM chatlogs_room WHERE user_from_id = ${user.id} ORDER BY timestamp DESC LIMIT 20`), - rawRows<{ - id: number; - timestamp: number; - command: string; - params: string; - succes: string; - }>(sql` + FROM chatlogs_room WHERE user_from_id = ${user.id} ORDER BY timestamp DESC LIMIT 20`).catch( + () => { + activityUnavailable = true; + return []; + }, + ) + : [], + access.logs + ? rawRows<{ + id: number; + timestamp: number; + command: string; + params: string; + succes: string; + }>(sql` SELECT ROW_NUMBER() OVER (ORDER BY timestamp DESC) AS id, timestamp, command, params, succes - FROM commandlogs WHERE user_id = ${user.id} ORDER BY timestamp DESC LIMIT 10`), + FROM commandlogs WHERE user_id = ${user.id} ORDER BY timestamp DESC LIMIT 10`).catch( + () => { + activityUnavailable = true; + return []; + }, + ) + : [], ]); const roomCount = Number(roomCountRows[0]?.total ?? 0); @@ -157,6 +185,7 @@ export async function loadUserById(id: string) { userRooms, chatLogs, commandLogs, + activityUnavailable, isOnline: user.online === "1", activeBan, }; diff --git a/src/app/admin/users/edit/[id]/page.tsx b/src/app/admin/users/edit/[id]/page.tsx index ea33179f..393722e5 100644 --- a/src/app/admin/users/edit/[id]/page.tsx +++ b/src/app/admin/users/edit/[id]/page.tsx @@ -1,5 +1,6 @@ import { sql } from "drizzle-orm"; import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { db } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; @@ -25,7 +26,18 @@ export default async function UserEditPage({ redirect("/admin"); } - const data = await loadUserById(id); + const overviewT = await getTranslations("pages.admin.users.overview"); + const canViewLogs = canAccess( + permissions, + PERMS.LOGS_VIEW, + session.user.rank, + ); + const canViewBans = canAccess( + permissions, + PERMS.BANS_VIEW, + session.user.rank, + ); + const data = await loadUserById(id, { logs: canViewLogs, bans: canViewBans }); const watched = await getWatchedUserIds(session.user.id); // Fetch rank list from permissions table for the rank dropdown @@ -45,6 +57,7 @@ export default async function UserEditPage({ duckets={data.duckets} roomCount={data.roomCount} chatCount={data.chatCount} + canViewLogs={canViewLogs && !data.activityUnavailable} tradeCount={data.tradeCount} isOnline={data.isOnline} activeBan={!!data.activeBan} @@ -57,17 +70,21 @@ export default async function UserEditPage({ Edit Profile - - Bans - {data.activeBan && ( - - )} - + {canViewBans && ( + + Bans + {data.activeBan && ( + + )} + + )} Badges ({data.user.badges.length}) Rooms ({data.roomCount}) - Activity + {canViewLogs && ( + Activity + )} Account Info @@ -88,20 +105,22 @@ export default async function UserEditPage({ /> - - ({ - id: b.id, - reason: b.banReason, - type: b.type, - staffName: b.staff.username, - timestamp: b.timestamp, - banExpire: b.banExpire, - }))} - /> - + {canViewBans && ( + + ({ + id: b.id, + reason: b.banReason, + type: b.type, + staffName: b.staff.username, + timestamp: b.timestamp, + banExpire: b.banExpire, + }))} + /> + + )} - - - + {canViewLogs && ( + + {data.activityUnavailable ? ( +

+ {overviewT("error")} +

+ ) : ( + + )} +
+ )} diff --git a/src/app/admin/users/show/[id]/page.tsx b/src/app/admin/users/show/[id]/page.tsx index ca3b66e2..18f9fe62 100644 --- a/src/app/admin/users/show/[id]/page.tsx +++ b/src/app/admin/users/show/[id]/page.tsx @@ -6,9 +6,9 @@ import { getWatchedUserIds } from "@/lib/services/watch"; import { UserAccountInfo } from "../../_components/user-account-info"; import { UserBadgesReadonly } from "../../_components/user-badges-readonly"; import { UserHero } from "../../_components/user-hero"; -import { UserSanctionTimeline } from "../../_components/user-sanction-timeline"; +import { UserOperationalOverview } from "../../_components/user-operational-overview"; import { loadUserById } from "../../_lib/load-user"; -import { loadUserSanctions } from "../../_lib/load-user-sanctions"; +import { loadUserOverview } from "../../_lib/load-user-overview"; import { UserActivityLog } from "../../[id]/user-activity-log"; import { UserQuickActions } from "../../[id]/user-quick-actions"; import { UserRooms } from "../../[id]/user-rooms"; @@ -25,10 +25,19 @@ export default async function UserShowPage({ } const t = await getTranslations("pages.admin.users.detail"); + const overviewT = await getTranslations("pages.admin.users.overview"); const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank); - const data = await loadUserById(id); - const [sanctions, watched] = await Promise.all([ - loadUserSanctions(data.user.id), + const canViewLogs = canAccess( + permissions, + PERMS.LOGS_VIEW, + session.user.rank, + ); + const data = await loadUserById(id, { + logs: canViewLogs, + bans: canAccess(permissions, PERMS.BANS_VIEW, session.user.rank), + }); + const [overview, watched] = await Promise.all([ + loadUserOverview(data.user.id, permissions, session.user.rank), getWatchedUserIds(session.user.id), ]); @@ -40,6 +49,7 @@ export default async function UserShowPage({ duckets={data.duckets} roomCount={data.roomCount} chatCount={data.chatCount} + canViewLogs={canViewLogs && !data.activityUnavailable} tradeCount={data.tradeCount} isOnline={data.isOnline} activeBan={!!data.activeBan} @@ -55,8 +65,9 @@ export default async function UserShowPage({ : undefined } > - + + {overviewT("title")} {t("tabInfo")} {t("tabBadges", { count: data.user.badges.length })} @@ -64,12 +75,15 @@ export default async function UserShowPage({ {t("tabRooms", { count: data.roomCount })} - - {t("tabSanctions", { count: sanctions.events.length })} - - {t("tabActivity")} + {canViewLogs && ( + {t("tabActivity")} + )} + + + + @@ -87,19 +101,23 @@ export default async function UserShowPage({ - - - - - - - + {canViewLogs && ( + + {data.activityUnavailable ? ( +

+ {overviewT("error")} +

+ ) : ( + + )} +
+ )}
{canEdit && ( diff --git a/src/app/api/admin/search/route.test.ts b/src/app/api/admin/search/route.test.ts new file mode 100644 index 00000000..e10bc90b --- /dev/null +++ b/src/app/api/admin/search/route.test.ts @@ -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(), + 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, + ) => { + 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; + condition: unknown; + limit: number; +}[] = []; +const dialect = new MySqlDialect(); +const rows = new Map(); + +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[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[0], + ).params, + ).toEqual([`%${"a".repeat(64)}%`]); + await search("12abc"); + expect( + dialect.sqlToQuery( + calls[2].condition as Parameters[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[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(); + }); +}); diff --git a/src/app/api/admin/search/route.ts b/src/app/api/admin/search/route.ts index 1076d7ca..4a893e0a 100644 --- a/src/app/api/admin/search/route.ts +++ b/src/app/api/admin/search/route.ts @@ -1,20 +1,24 @@ -import { eq, like, or } from "drizzle-orm"; +import { asc, eq, like, or } from "drizzle-orm"; import { NextResponse } from "next/server"; import { ADMIN_NAV_GROUPS, navItemIsAllowed } from "@/lib/admin-nav"; import { apiOk } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; import { + CatalogPages, + CatalogPagesBc, db, Guilds, + ItemsBase, Rooms, User, WebsiteArticles, + WebsiteHelpCenterTickets, WebsiteRareValues, WebsiteShopArticles, + WebsiteTicket, } from "@/lib/db"; import { canAccess, PERMS } from "@/lib/permissions"; import { rateLimit } from "@/lib/rate-limit"; - export type AdminSearchResult = { type: string; id: number | string; @@ -22,16 +26,13 @@ export type AdminSearchResult = { subtitle: string; url: string; }; - const PER_TYPE = 5; const MAX_TOTAL = 20; const MAX_QUERY_LENGTH = 64; - /** Escape LIKE wildcards so user input can't widen the match. */ -export function escapeLike(input: string): string { +function escapeLike(input: string): string { return input.replace(/[\\%_]/g, (m) => `\\${m}`); } - export const GET = withAdmin( { permission: PERMS.ADMIN_DASHBOARD }, async (request, context) => { @@ -46,14 +47,12 @@ export const GET = withAdmin( { status: 429, headers: { "retry-after": String(limited.retryAfter) } }, ); } - const q = (request.nextUrl.searchParams.get("q") || "") .trim() .slice(0, MAX_QUERY_LENGTH); - if (q.length < 2) { + if (q.length < 2 && !/^[1-9]$/.test(q)) { return apiOk({ results: [] }); } - const canSearch = (href: string) => { const item = ADMIN_NAV_GROUPS.flatMap((group) => group.items).find( (item) => item.href === href, @@ -67,123 +66,272 @@ export const GET = withAdmin( }) ); }; - const pattern = `%${escapeLike(q)}%`; const idExact = Number.parseInt(q, 10); - const hasId = Number.isFinite(idExact) && String(idExact) === q; - - const [users, articles, rooms, guilds, shopArticles, rareValues] = - await Promise.all([ - canSearch("/admin/users") - ? db - .select({ - id: User.id, - title: User.username, - subtitle: User.mail, - }) - .from(User) - .where( - or( - like(User.username, pattern), - like(User.mail, pattern), - ...(hasId ? [eq(User.id, idExact)] : []), - ), - ) - .limit(PER_TYPE) - : Promise.resolve([]), - - canSearch("/admin/articles") - ? db - .select({ - id: WebsiteArticles.id, - title: WebsiteArticles.title, - subtitle: WebsiteArticles.slug, - }) - .from(WebsiteArticles) - .where( - or( - like(WebsiteArticles.title, pattern), - like(WebsiteArticles.slug, pattern), - ), - ) - .limit(PER_TYPE) - : Promise.resolve([]), - - canSearch("/admin/rooms") - ? db - .select({ - id: Rooms.id, - title: Rooms.name, - subtitle: Rooms.ownerName, - }) - .from(Rooms) - .where( - or( - like(Rooms.name, pattern), - ...(hasId ? [eq(Rooms.id, idExact)] : []), - ), - ) - .limit(PER_TYPE) - : Promise.resolve([]), - - canSearch("/admin/guilds") - ? db - .select({ - id: Guilds.id, - title: Guilds.name, - subtitle: Guilds.description, - }) - .from(Guilds) - .where( - or( - like(Guilds.name, pattern), - ...(hasId ? [eq(Guilds.id, idExact)] : []), - ), - ) - .limit(PER_TYPE) - : Promise.resolve([]), - - canSearch("/admin/shop") - ? db - .select({ - id: WebsiteShopArticles.id, - title: WebsiteShopArticles.name, - subtitle: WebsiteShopArticles.info, - }) - .from(WebsiteShopArticles) - .where( - or( - like(WebsiteShopArticles.name, pattern), - ...(hasId - ? [eq(WebsiteShopArticles.id, BigInt(idExact))] - : []), - ), - ) - .limit(PER_TYPE) - : Promise.resolve([]), - - canSearch("/admin/rare-values") - ? db - .select({ - id: WebsiteRareValues.id, - title: WebsiteRareValues.name, - subtitle: WebsiteRareValues.itemId, - }) - .from(WebsiteRareValues) - .where( - or( - like(WebsiteRareValues.name, pattern), - ...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []), - ), - ) - .limit(PER_TYPE) - : Promise.resolve([]), - ]); - + const hasId = + Number.isSafeInteger(idExact) && idExact > 0 && String(idExact) === q; + let partial = false; + const [ + users, + articles, + rooms, + guilds, + shopArticles, + rareValues, + furniture, + catalog, + catalogBc, + tickets, + helpTickets, + ] = await Promise.all([ + canSearch("/admin/users") + ? db + .select({ + id: User.id, + title: User.username, + }) + .from(User) + .where( + or( + like(User.username, pattern), + ...(hasId ? [eq(User.id, idExact)] : []), + ), + ) + .orderBy(asc(User.id)) + .limit(PER_TYPE) + .catch(() => { + partial = true; + return []; + }) + : Promise.resolve([]), + canSearch("/admin/articles") + ? db + .select({ + id: WebsiteArticles.id, + title: WebsiteArticles.title, + subtitle: WebsiteArticles.slug, + }) + .from(WebsiteArticles) + .where( + or( + like(WebsiteArticles.title, pattern), + like(WebsiteArticles.slug, pattern), + ), + ) + .orderBy(asc(WebsiteArticles.id)) + .limit(PER_TYPE) + .catch(() => { + partial = true; + return []; + }) + : Promise.resolve([]), + canSearch("/admin/rooms") + ? db + .select({ + id: Rooms.id, + title: Rooms.name, + subtitle: Rooms.ownerName, + }) + .from(Rooms) + .where( + or( + like(Rooms.name, pattern), + ...(hasId ? [eq(Rooms.id, idExact)] : []), + ), + ) + .orderBy(asc(Rooms.id)) + .limit(PER_TYPE) + .catch(() => { + partial = true; + return []; + }) + : Promise.resolve([]), + canSearch("/admin/guilds") + ? db + .select({ + id: Guilds.id, + title: Guilds.name, + subtitle: Guilds.description, + }) + .from(Guilds) + .where( + or( + like(Guilds.name, pattern), + ...(hasId ? [eq(Guilds.id, idExact)] : []), + ), + ) + .orderBy(asc(Guilds.id)) + .limit(PER_TYPE) + .catch(() => { + partial = true; + return []; + }) + : Promise.resolve([]), + canSearch("/admin/shop") + ? db + .select({ + id: WebsiteShopArticles.id, + title: WebsiteShopArticles.name, + subtitle: WebsiteShopArticles.info, + }) + .from(WebsiteShopArticles) + .where( + or( + like(WebsiteShopArticles.name, pattern), + ...(hasId ? [eq(WebsiteShopArticles.id, BigInt(idExact))] : []), + ), + ) + .orderBy(asc(WebsiteShopArticles.id)) + .limit(PER_TYPE) + .catch(() => { + partial = true; + return []; + }) + : Promise.resolve([]), + canSearch("/admin/rare-values") + ? db + .select({ + id: WebsiteRareValues.id, + title: WebsiteRareValues.name, + subtitle: WebsiteRareValues.itemId, + }) + .from(WebsiteRareValues) + .where( + or( + like(WebsiteRareValues.name, pattern), + ...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []), + ), + ) + .orderBy(asc(WebsiteRareValues.id)) + .limit(PER_TYPE) + .catch(() => { + partial = true; + return []; + }) + : Promise.resolve([]), + canAccess( + context.permissions, + PERMS.CATALOG_VIEW, + context.session.user.rank, + ) + ? db + .select({ + id: ItemsBase.id, + title: ItemsBase.publicName, + subtitle: ItemsBase.itemName, + }) + .from(ItemsBase) + .where( + or( + like(ItemsBase.publicName, pattern), + like(ItemsBase.itemName, pattern), + ...(hasId ? [eq(ItemsBase.id, idExact)] : []), + ), + ) + .orderBy(asc(ItemsBase.id)) + .limit(PER_TYPE) + .catch(() => { + partial = true; + return []; + }) + : Promise.resolve([]), + canAccess( + context.permissions, + PERMS.CATALOG_VIEW, + context.session.user.rank, + ) + ? db + .select({ id: CatalogPages.id, title: CatalogPages.caption }) + .from(CatalogPages) + .where( + or( + like(CatalogPages.caption, pattern), + ...(hasId ? [eq(CatalogPages.id, idExact)] : []), + ), + ) + .orderBy(asc(CatalogPages.id)) + .limit(PER_TYPE) + .catch(() => { + partial = true; + return []; + }) + : Promise.resolve([]), + canAccess( + context.permissions, + PERMS.CATALOG_VIEW, + context.session.user.rank, + ) + ? db + .select({ id: CatalogPagesBc.id, title: CatalogPagesBc.caption }) + .from(CatalogPagesBc) + .where( + or( + like(CatalogPagesBc.caption, pattern), + ...(hasId ? [eq(CatalogPagesBc.id, idExact)] : []), + ), + ) + .orderBy(asc(CatalogPagesBc.id)) + .limit(PER_TYPE) + .catch(() => { + partial = true; + return []; + }) + : Promise.resolve([]), + canAccess( + context.permissions, + PERMS.TICKETS_VIEW, + context.session.user.rank, + ) + ? db + .select({ id: WebsiteTicket.id, title: WebsiteTicket.subject }) + .from(WebsiteTicket) + .where( + or( + like(WebsiteTicket.subject, pattern), + ...(hasId ? [eq(WebsiteTicket.id, idExact)] : []), + ), + ) + .orderBy(asc(WebsiteTicket.id)) + .limit(PER_TYPE) + .catch(() => { + partial = true; + return []; + }) + : Promise.resolve([]), + canAccess( + context.permissions, + PERMS.TICKETS_VIEW, + context.session.user.rank, + ) + ? db + .select({ + id: WebsiteHelpCenterTickets.id, + title: WebsiteHelpCenterTickets.title, + }) + .from(WebsiteHelpCenterTickets) + .where( + or( + like(WebsiteHelpCenterTickets.title, pattern), + ...(hasId + ? [eq(WebsiteHelpCenterTickets.id, BigInt(idExact))] + : []), + ), + ) + .orderBy(asc(WebsiteHelpCenterTickets.id)) + .limit(PER_TYPE) + .catch(() => { + partial = true; + return []; + }) + : Promise.resolve([]), + ]); const groupMap: Record< string, { type: string; items: Array } > = { + furniture: { type: "furniture", items: [] }, + catalog: { type: "catalog", items: [] }, + tickets: { type: "tickets", items: [] }, users: { type: "users", items: [] }, articles: { type: "articles", items: [] }, rooms: { type: "rooms", items: [] }, @@ -191,13 +339,12 @@ export const GET = withAdmin( shop: { type: "shop", items: [] }, rareValues: { type: "rareValues", items: [] }, }; - for (const r of users) { groupMap.users.items.push({ type: "users", id: r.id, title: r.title, - subtitle: r.subtitle || "", + subtitle: `#${r.id}`, url: `/admin/users/show/${r.id}`, }); } @@ -246,10 +393,53 @@ export const GET = withAdmin( url: `/admin/rare-values/${r.id}`, }); } - + for (const r of furniture) { + groupMap.furniture.items.push({ + type: "furniture", + id: r.id, + title: r.title || r.subtitle || `#${r.id}`, + subtitle: `${r.subtitle || ""} · #${r.id}`, + url: `/admin/items/${r.id}`, + }); + } + for (const [rows, kind] of [ + [catalog, "normal"], + [catalogBc, "bc"], + ] as const) { + for (const r of rows) + groupMap.catalog.items.push({ + type: "catalog", + id: `${kind}-${r.id}`, + title: r.title || `#${r.id}`, + subtitle: kind === "bc" ? `BC · #${r.id}` : `#${r.id}`, + url: + kind === "bc" + ? `/admin/catalog/builder-club/${r.id}` + : `/admin/catalog/${r.id}`, + }); + } + for (const r of tickets) + groupMap.tickets.items.push({ + type: "tickets", + id: `cms-${r.id}`, + title: r.title, + subtitle: `#${r.id}`, + url: `/admin/tickets/${r.id}`, + }); + for (const r of helpTickets) + groupMap.tickets.items.push({ + type: "tickets", + id: `help-${r.id}`, + title: r.title, + subtitle: `#${r.id}`, + url: `/admin/help-tickets/${r.id}`, + }); const results: AdminSearchResult[] = []; const order = [ "users", + "furniture", + "catalog", + "tickets", "articles", "rooms", "guilds", @@ -268,7 +458,6 @@ export const GET = withAdmin( } if (!added) break; } - - return apiOk({ results }); + return apiOk({ results, partial }); }, ); diff --git a/src/components/admin/search-dialog.tsx b/src/components/admin/search-dialog.tsx index e615f877..4ba7db9f 100644 --- a/src/components/admin/search-dialog.tsx +++ b/src/components/admin/search-dialog.tsx @@ -18,18 +18,21 @@ import { DialogContent, DialogDescription, DialogTitle, + DialogTrigger, } from "@/components/ui/dialog"; import { useDebounce } from "@/hooks/use-debounce"; import { type AdminNavigationOptions, visibleAdminNavigation, } from "@/lib/admin-navigation"; +import { AdminSearchRequest } from "@/lib/admin-search-request"; type SearchResult = AdminSearchResult; type SearchResponse = { ok: boolean; results: SearchResult[]; + partial?: boolean; }; export function SearchDialog({ @@ -56,8 +59,9 @@ export function SearchDialog({ const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [failed, setFailed] = useState(false); + const [partial, setPartial] = useState(false); const debouncedQuery = useDebounce(query, 250); - const abortRef = useRef(null); + const requestRef = useRef(new AdminSearchRequest()); const groupLabels: Record = { users: t("users"), @@ -66,19 +70,22 @@ export function SearchDialog({ guilds: t("guilds"), shop: t("shop"), rareValues: t("rareValues"), + furniture: t("furniture"), + catalog: t("catalog"), + tickets: t("tickets"), }; const fetchResults = useCallback(async (q: string) => { - abortRef.current?.abort(); + requestRef.current.cancel(); setFailed(false); + setPartial(false); setResults([]); - if (q.length < 2) { + if (!canSearchRecords(q)) { setResults([]); setLoading(false); return; } - const controller = new AbortController(); - abortRef.current = controller; + const controller = requestRef.current.start(); setLoading(true); try { const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, { @@ -86,28 +93,30 @@ export function SearchDialog({ }); if (!res.ok) throw new Error("Search request failed"); const data: SearchResponse = await res.json(); - if (abortRef.current !== controller || controller.signal.aborted) return; - if (data.ok) setResults(data.results); - else setFailed(true); + if (!requestRef.current.accepts(controller)) return; + if (data.ok) { + setResults(data.results); + setPartial(Boolean(data.partial)); + } else setFailed(true); } catch (error) { if (error instanceof DOMException && error.name === "AbortError") return; - if (abortRef.current === controller) { + if (requestRef.current.accepts(controller)) { setResults([]); setFailed(true); } } finally { - if (abortRef.current === controller) setLoading(false); + if (requestRef.current.accepts(controller)) setLoading(false); } }, []); useEffect(() => { - if (!open) return; + if (!open || query !== debouncedQuery) return; fetchResults(debouncedQuery); - return () => abortRef.current?.abort(); - }, [debouncedQuery, fetchResults, open]); + return () => requestRef.current.cancel(); + }, [debouncedQuery, fetchResults, open, query]); useEffect(() => { - return () => abortRef.current?.abort(); + return () => requestRef.current.cancel(); }, []); useEffect(() => { @@ -121,8 +130,29 @@ export function SearchDialog({ return () => document.removeEventListener("keydown", handleKeyDown); }, []); + function handleQueryChange(value: string) { + requestRef.current.cancel(); + setQuery(value.slice(0, 64)); + setResults([]); + setFailed(false); + setPartial(false); + setLoading(canSearchRecords(value)); + } + + function handleOpenChange(next: boolean) { + if (!next) { + requestRef.current.cancel(); + setQuery(""); + setResults([]); + setFailed(false); + setPartial(false); + setLoading(false); + } + setOpen(next); + } + function handleSelect(url: string) { - setOpen(false); + handleOpenChange(false); setQuery(""); setResults([]); router.push(url); @@ -132,19 +162,18 @@ export function SearchDialog({ const pages = getPages(query.trim().toLocaleLowerCase()).slice(0, 8); return ( - - + {t("placeholder")} @@ -157,21 +186,33 @@ export function SearchDialog({ - {loading && query.length >= 2 ? ( + {loading && canSearchRecords(query) ? (
…
) : null} - {!loading && !failed && ( + {!loading && !failed && !partial && canSearchRecords(query) && ( {t("noResults")} )} - {failed && ( + {!canSearchRecords(query) && ( +

{t("hint")}

+ )} + {(failed || partial) && (

- {t("loadError")} + {t(partial ? "partialError" : "loadError")} +

)} {pages.length > 0 && ( @@ -200,7 +241,9 @@ export function SearchDialog({ onSelect={() => handleSelect(item.url)} className="flex flex-col items-start gap-0.5 py-2" > - {item.title} + + {item.title} + {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[]) { const grouped: Record = {}; for (const r of results) { diff --git a/src/lib/admin-search-permissions.test.ts b/src/lib/admin-search-permissions.test.ts index 52b16131..48b95f01 100644 --- a/src/lib/admin-search-permissions.test.ts +++ b/src/lib/admin-search-permissions.test.ts @@ -54,6 +54,9 @@ vi.mock("@/lib/db", () => { where() { return query; }, + orderBy() { + return query; + }, async limit() { state.queried.push(table); return [{ id: 1, title: "Match", subtitle: "Data" }]; diff --git a/src/lib/admin-search-request.test.ts b/src/lib/admin-search-request.test.ts new file mode 100644 index 00000000..3aa3612c --- /dev/null +++ b/src/lib/admin-search-request.test.ts @@ -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); + }); +}); diff --git a/src/lib/admin-search-request.ts b/src/lib/admin-search-request.ts new file mode 100644 index 00000000..98a54c84 --- /dev/null +++ b/src/lib/admin-search-request.ts @@ -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; + } +} diff --git a/src/lib/staff-smoke-contract.test.ts b/src/lib/staff-smoke-contract.test.ts index dbc4dec3..9a2eebbc 100644 --- a/src/lib/staff-smoke-contract.test.ts +++ b/src/lib/staff-smoke-contract.test.ts @@ -36,16 +36,18 @@ describe("staff smoke contract", () => { expect(detail).toContain("canLiftBan"); }); - it("exposes sanction timeline on user show", () => { + it("exposes operational overview on user show", () => { expect( - existsSync("src/app/admin/users/_components/user-sanction-timeline.tsx"), + existsSync( + "src/app/admin/users/_components/user-operational-overview.tsx", + ), ).toBe(true); - expect(existsSync("src/app/admin/users/_lib/load-user-sanctions.ts")).toBe( + expect(existsSync("src/app/admin/users/_lib/load-user-overview.ts")).toBe( true, ); expect( readFileSync("src/app/admin/users/show/[id]/page.tsx", "utf8"), - ).toContain("UserSanctionTimeline"); + ).toContain("UserOperationalOverview"); }); it("applies CMS SQL from drizzle/migrations (not Prisma)", () => { diff --git a/src/messages/ar.json b/src/messages/ar.json index a647c18f..e010a89a 100644 --- a/src/messages/ar.json +++ b/src/messages/ar.json @@ -281,6 +281,96 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "users": { + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } + } + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "myDashboard": { diff --git a/src/messages/bg.json b/src/messages/bg.json index ba62b043..7719e7ae 100644 --- a/src/messages/bg.json +++ b/src/messages/bg.json @@ -1232,6 +1232,84 @@ "save": "Запазете промените", "saving": "Запазва се...", "cancel": "Отказ" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3237,6 +3315,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "error": { diff --git a/src/messages/cs.json b/src/messages/cs.json index c3f7b9c8..cc0f47fb 100644 --- a/src/messages/cs.json +++ b/src/messages/cs.json @@ -1232,6 +1232,84 @@ "save": "Uložte změny", "saving": "Ukládání…", "cancel": "Zrušit" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3237,6 +3315,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "error": { diff --git a/src/messages/da.json b/src/messages/da.json index c035bcdd..329ddfc4 100644 --- a/src/messages/da.json +++ b/src/messages/da.json @@ -1232,6 +1232,84 @@ "save": "Save changes", "saving": "Gemmer...", "cancel": "Annuller" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3237,6 +3315,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "error": { diff --git a/src/messages/de.json b/src/messages/de.json index b0cab306..c6e2a0b8 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -1203,6 +1203,84 @@ "save": "Änderungen speichern", "saving": "Wird gespeichert…", "cancel": "Abbrechen" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3208,6 +3286,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "radioRequests": { diff --git a/src/messages/el.json b/src/messages/el.json index d303a5c5..90f5ae16 100644 --- a/src/messages/el.json +++ b/src/messages/el.json @@ -1232,6 +1232,84 @@ "save": "Αποθήκευση αλλαγών", "saving": "Αποθήκευση…", "cancel": "Ακύρωση" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3237,6 +3315,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "error": { diff --git a/src/messages/en.json b/src/messages/en.json index ccb9504d..f533d2c0 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -1566,6 +1566,84 @@ "save": "Save changes", "saving": "Saving…", "cancel": "Cancel" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3815,7 +3893,7 @@ }, "search": { "pages": "Pages and tools", - "description": "Find an admin page or search hotel records.", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", "loadError": "Records could not be loaded. You can still open the pages above.", "placeholder": "Search pages, users, rooms…", "users": "Users", @@ -3824,7 +3902,14 @@ "guilds": "Groups", "shop": "Shop", "rareValues": "Rare values", - "noResults": "No results found." + "noResults": "No results found.", + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "partialError": "Some results could not be loaded. Try again." }, "table": { "search": "Search…", diff --git a/src/messages/es.json b/src/messages/es.json index fac229ca..0616370d 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -1203,6 +1203,84 @@ "save": "Guardar cambios", "saving": "Guardando…", "cancel": "Cancelar" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3208,6 +3286,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "radioRequests": { diff --git a/src/messages/fi.json b/src/messages/fi.json index 06e54dcc..d434b6a2 100644 --- a/src/messages/fi.json +++ b/src/messages/fi.json @@ -281,6 +281,96 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "users": { + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } + } + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "myDashboard": { diff --git a/src/messages/fr.json b/src/messages/fr.json index 7877b30b..ed1d14ad 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -1203,6 +1203,84 @@ "save": "Enregistrer les modifications", "saving": "Enregistrement…", "cancel": "Annuler" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3208,6 +3286,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "radioRequests": { diff --git a/src/messages/hr.json b/src/messages/hr.json index 48d1f938..acc505d3 100644 --- a/src/messages/hr.json +++ b/src/messages/hr.json @@ -1232,6 +1232,84 @@ "save": "Spremite promjene", "saving": "Spremanje...", "cancel": "Odustani" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3237,6 +3315,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "error": { diff --git a/src/messages/hu.json b/src/messages/hu.json index cdb7718e..8708b8ef 100644 --- a/src/messages/hu.json +++ b/src/messages/hu.json @@ -1232,6 +1232,84 @@ "save": "Módosítások mentése", "saving": "Mentés…", "cancel": "Mégse" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3237,6 +3315,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "error": { diff --git a/src/messages/it.json b/src/messages/it.json index 3537f755..96832149 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -21,8 +21,8 @@ "friends": "Amici", "messages": "Messaggi", "admin": "Admin", - "openMenu": "Open menu", - "closeMenu": "Close menu", + "openMenu": "Apri menu", + "closeMenu": "Chiudi menu", "drawBadge": "Crea distintivo", "mod": "Mod" }, @@ -991,7 +991,7 @@ "economy": "Economia", "catalog": "Catalogo", "shop": "Negozio", - "marketplace": "Marketplace", + "marketplace": "Mercatino", "transactions": "Transazioni", "vouchers": "Buoni", "subscriptions": "Abbonamenti", @@ -1042,12 +1042,12 @@ "hubNewsMedia": "Notizie e media", "hubSiteContent": "Contenuti sito", "hubEngagement": "Community", - "hubUsers": "Users", - "hubAccess": "Access & security", - "hubCatalog": "Catalog & rares", - "hubShop": "Shop & payments", - "hubTools": "Tools", - "hubObservability": "Observability", + "hubUsers": "Utenti", + "hubAccess": "Accesso e sicurezza", + "hubCatalog": "Catalogo e rari", + "hubShop": "Negozio e pagamenti", + "hubTools": "Strumenti", + "hubObservability": "Monitoraggio e diagnostica", "community": "Community", "monitoring": "Monitoraggio", "staffAccess": "Gradi e permessi", @@ -1057,7 +1057,7 @@ "tradeLog": "Log trade", "tools": "Strumenti", "guilds": "Gruppi", - "itemsBase": "Items base", + "itemsBase": "Definizioni furni", "studio": "Studio", "peopleSupport": "Utenti e assistenza", "catalogFurniture": "Catalogo e furni", @@ -1519,11 +1519,11 @@ "resetPassword": "Reimposta password", "deleteUser": "Elimina utente", "confirmDelete": "Sei sicuro di voler eliminare {username}? Questa azione è irreversibile.", - "tabInfo": "Account Info", - "tabBadges": "Badges ({count})", - "tabRooms": "Rooms ({count})", - "tabSanctions": "Sanctions ({count})", - "tabActivity": "Activity" + "tabInfo": "Informazioni account", + "tabBadges": "Distintivi ({count})", + "tabRooms": "Stanze ({count})", + "tabSanctions": "Sanzioni ({count})", + "tabActivity": "Attività" }, "editForm": { "title": "Modifica utente: {username}", @@ -1541,6 +1541,84 @@ "save": "Salva modifiche", "saving": "Salvataggio…", "cancel": "Annulla" + }, + "presentation": { + "users": "Utenti", + "edit": "Modifica", + "view": "Visualizza", + "rank": "Grado {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Bannato", + "credits": "crediti", + "diamonds": "diamanti", + "duckets": "duckets", + "pixels": "pixel", + "rooms": "Stanze", + "messages": "Messaggi", + "trades": "Scambi", + "accountDetails": "Dettagli account", + "registered": "Registrazione", + "lastLogin": "Ultimo accesso", + "ipRegister": "IP di registrazione", + "ipCurrent": "IP attuale", + "machineId": "ID dispositivo", + "email": "Email", + "gender": "Genere", + "never": "Mai", + "findIp": "Cerca utenti con questo IP", + "enabled": "Attiva", + "disabled": "Disattiva", + "unavailable": "Non disponibile", + "noBadges": "Nessun distintivo assegnato" + }, + "create": { + "newUser": "Nuovo utente", + "title": "Crea utente", + "description": "Crea subito l’account con un avatar predefinito e 5.000 crediti, pixel, duckets e diamanti.", + "username": "Nome utente", + "email": "Email", + "password": "Password", + "rank": "Grado", + "rankOption": "Grado {rank}", + "motto": "Motto (facoltativo)", + "usernamePlaceholder": "NuovoHabbo", + "passwordPlaceholder": "Almeno 6 caratteri", + "mottoPlaceholder": "Sono nuovo qui!", + "generate": "Genera", + "passwordGenerated": "Password generata nel campo password.", + "required": "Nome utente, email e password sono obbligatori.", + "invalidUsername": "Usa da 3 a 20 caratteri: lettere, numeri, _ o -.", + "passwordLength": "La password deve contenere almeno 6 caratteri.", + "created": "Utente {username} creato." + }, + "overview": { + "title": "Riepilogo", + "hint": "Fino a 5 record recenti per fonte. Sono mostrate solo le sezioni a cui hai accesso.", + "restricted": "Non hai i permessi per visualizzare i dati operativi.", + "sanctions": "Ban recenti", + "mute": "Mute attivo", + "tickets": "Ticket di assistenza", + "help": "Richieste al centro assistenza", + "reports": "Segnalazioni in gioco", + "transactions": "Pagamenti", + "purchases": "Acquisti dal catalogo", + "activity": "Azioni dello staff registrate", + "openModule": "Apri gestione", + "purchaseHint": "Quantità e costi nelle valute registrate.", + "purchaseDetail": "Quantità: {amount} · Crediti: {credits} · Punti: {points} (tipo {type})", + "activityHint": "Voci del registro staff relative a questo account. Non è la cronologia completa delle attività del giocatore.", + "muteUntil": "Mute fino al {date}", + "error": "Impossibile caricare questa fonte. Ricarica la pagina per riprovare.", + "empty": "Nessun record trovato.", + "status": { + "active": "Attivo", + "expired": "Scaduto", + "open": "Aperto", + "closed": "Chiuso", + "reported": "Utente segnalato", + "reporter": "Segnalante" + } } }, "bans": { @@ -1686,19 +1764,19 @@ "actionsTitle": "Azioni", "requesterTitle": "Richiedente", "detailsTitle": "Dettagli", - "rankLabel": "Rank {rank}", + "rankLabel": "Grado {rank}", "messageCountLabel": "Messaggi", "success": { "replied": "Risposta inviata.", "closed": "Ticket chiuso.", "reopened": "Ticket riaperto.", - "banLifted": "Ban lifted and ticket closed." + "banLifted": "Ban revocato e ticket chiuso." }, "queueHint": "Questa coda e il help center pubblico. I ticket del supporto CMS stanno nella tab Supporto CMS.", "queueOtherCta": "Apri supporto CMS", "queueCounts": "Aperti qui: {here} · Supporto CMS: {other}", - "liftBanSubmit": "Lift requester ban & close", - "requesterBanned": "Requester banned" + "liftBanSubmit": "Revoca il ban del richiedente e chiudi", + "requesterBanned": "Richiedente bannato" }, "cfh": { "title": "Call for Help", @@ -3383,11 +3461,11 @@ "urlCopied": "URL copiato negli appunti" }, "hubs": { - "newsMedia": "News & media", + "newsMedia": "Notizie e contenuti multimediali", "newsMediaSubtitle": "Articoli, foto, banner, ads e media library", "siteContent": "Contenuti sito", "siteContentSubtitle": "Navigazione, help, box, tag e prefissi", - "engagement": "Engagement", + "engagement": "Attività della community", "engagementSubtitle": "Eventi, sondaggi e ticket di supporto", "users": "Utenti", "usersSubtitle": "Directory, online, candidature e team", @@ -3411,10 +3489,10 @@ "articles": "Articoli", "photos": "Foto", "banners": "Banner", - "ads": "Ads", + "ads": "Pubblicità", "media": "Media", - "navigator": "Navigator", - "help": "Help", + "navigator": "Navigatore", + "help": "Assistenza", "boxes": "Box", "tags": "Tag", "prefixes": "Prefissi", @@ -3422,9 +3500,9 @@ "eventTypes": "Tipi evento", "polls": "Sondaggi", "tickets": "Supporto CMS", - "helpTickets": "Help center", + "helpTickets": "Centro assistenza", "templates": "Template", - "directory": "Directory", + "directory": "Elenco utenti", "multiAccounts": "Multi-account", "online": "Online", "applications": "Candidature", @@ -3433,26 +3511,26 @@ "ip": "IP", "vpn": "VPN", "permissions": "Permessi", - "wordFilter": "Word filter", + "wordFilter": "Filtro parole", "overview": "Panoramica", "actions": "Azioni", - "cfh": "CFH", + "cfh": "Richieste di moderazione", "team": "Team", "catalogTab": "Catalogo", "rares": "Rare", "badges": "Badge", "achievements": "Achievement", "sounds": "Suoni", - "shopTab": "Shop", + "shopTab": "Negozio", "transactions": "Transazioni", "vouchers": "Voucher", "subscriptions": "Abbonamenti", "calendar": "Calendario", "history": "Cronologia", "moderationTab": "Moderazione", - "monitoring": "Monitoring", + "monitoring": "Monitoraggio", "settingsTab": "Impostazioni", - "embed": "Embed", + "embed": "Incorporamento", "apiKeys": "API key", "points": "Punti", "ranks": "Rank", @@ -3461,37 +3539,37 @@ "theme": "Tema", "maintenance": "Manutenzione", "favicon": "Favicon", - "emulator": "Emulator", + "emulator": "Emulatore", "email": "Email", "housekeeping": "Archivio permessi", - "commando": "Commando", + "commando": "Centro operativo", "rooms": "Stanze", - "import": "Import", + "import": "Importazione", "importOverview": "Panoramica", "importBadges": "Badge", "importFurni": "Furni", - "importUpload": "Upload", + "importUpload": "Caricamento", "importClothing": "Abbigliamento", "importEffects": "Effetti", "importPets": "Pet", - "importClone": "Clone", - "importSync": "Sync", + "importClone": "Clonazione", + "importSync": "Sincronizzazione", "importRepairIcons": "Ripara icone", - "importAudit": "Audit", + "importAudit": "Verifica dati", "translations": "Traduzioni", "logs": "Log", - "audit": "Audit", + "audit": "Registro modifiche", "chat": "Chat", "commands": "Comandi", "trades": "Trade", - "analytics": "Analytics", + "analytics": "Statistiche", "activity": "Attività", "economy": "Economia", "devops": "DevOps", "errors": "Errori", "alerts": "Alert", "toolsGroup": "Strumenti", - "ticketInbox": "Inbox", + "ticketInbox": "Ticket ricevuti", "cmsErrors": "Errori CMS", "themeBuilder": "Editor del tema", "studio": "Studio" @@ -3506,7 +3584,7 @@ "analyticsSubtitle": "Attività hotel, retention ed economia", "devops": "DevOps", "devopsSubtitle": "Salute servizi ed errori emulator", - "import": "Import", + "import": "Importazione", "importSubtitle": "Furni, badge, abbigliamento e strumenti asset" }, "settings": { @@ -3790,7 +3868,7 @@ }, "search": { "pages": "Pagine e strumenti", - "description": "Trova una pagina di gestione o cerca nei dati dell’hotel.", + "description": "Cerca pagine, utenti, furni, categorie del catalogo, articoli e ticket di assistenza.", "loadError": "Impossibile caricare i risultati. Puoi comunque aprire le pagine elencate.", "placeholder": "Cerca pagine, utenti, stanze…", "users": "Utenti", @@ -3799,7 +3877,14 @@ "guilds": "Gruppi", "shop": "Negozio", "rareValues": "Valori dei rari", - "noResults": "Nessun risultato trovato." + "noResults": "Nessun risultato trovato.", + "furniture": "Furni", + "catalog": "Categorie del catalogo", + "tickets": "Ticket di assistenza", + "loading": "Ricerca in corso…", + "hint": "Inserisci un ID numerico o almeno 2 caratteri oppure scegli una pagina.", + "retry": "Riprova", + "partialError": "Alcuni risultati non sono disponibili. Riprova." }, "table": { "search": "Cerca…", diff --git a/src/messages/ja.json b/src/messages/ja.json index 0dae330d..e0521b5e 100644 --- a/src/messages/ja.json +++ b/src/messages/ja.json @@ -281,6 +281,96 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "users": { + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } + } + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "myDashboard": { diff --git a/src/messages/nl.json b/src/messages/nl.json index f0b0de6b..7fc606ea 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -5,7 +5,7 @@ "articles": "Artikelen", "photos": "Foto's", "staff": "Team", - "rankings": "Rankings", + "rankings": "Ranglijsten", "guilds": "Gildes", "events": "Evenementen", "polls": "Peilingen", @@ -20,7 +20,7 @@ "radio": "Radio", "friends": "Vrienden", "messages": "Berichten", - "admin": "Admin", + "admin": "Beheer", "openMenu": "Menu openen", "closeMenu": "Menu sluiten", "drawBadge": "Badge tekenen", @@ -987,26 +987,26 @@ "photos": "Foto's", "tickets": "Supportbalie", "helpTickets": "Helpcentrum-tickets", - "events": "Events", + "events": "Evenementen", "polls": "Polls", "guilds": "Gildes", "banners": "Banners", - "prefixes": "Prefixes", + "prefixes": "Voorvoegsels", "usersAndAccess": "Gebruikers & toegang", "users": "Gebruikers", - "bans": "Bans", + "bans": "Verbanningen", "ipManagement": "IP-beheer", "vpn": "VPN", "applications": "Sollicitaties", "teams": "Teams", "permissions": "Permissies", - "onlineUsers": "Online users", - "moderation": "Moderation", + "onlineUsers": "Online gebruikers", + "moderation": "Moderatie", "economy": "Economie", "catalog": "Catalogus", "catalogMaintenance": "Catalogus-onderhoud", "shop": "Winkel", - "marketplace": "Marketplace", + "marketplace": "Marktplaats", "transactions": "Transacties", "vouchers": "Vouchers", "subscriptions": "Abonnementen", @@ -1017,11 +1017,11 @@ "radio": "Radio", "radioHistory": "Radio geschiedenis", "radioBanners": "Banners", - "radioEmbed": "Embed", + "radioEmbed": "Insluiten", "radioApiKeys": "API-sleutels", "radioModeration": "Moderatie", "radioSettings": "Instellingen", - "radioMonitoring": "Monitoring", + "radioMonitoring": "Bewaking", "radioPoints": "Punten", "radioRanks": "Rangen", "radioAutodj": "Auto DJ", @@ -1030,8 +1030,8 @@ "emulator": "Emulator", "alerts": "Meldingen", "logs": "Logboeken", - "auditLog": "Audit log", - "analytics": "Analytics", + "auditLog": "Wijzigingslogboek", + "analytics": "Statistieken", "devops": "DevOps", "wordFilter": "Woordfilter", "maintenance": "Onderhoud", @@ -1053,26 +1053,44 @@ "housekeeping": "Permissie-archief", "hotelManagementPanel": "Hotelbeheerpaneel", "rankLabel": "Rang {rank}", - "hubNewsMedia": "News & media", - "hubSiteContent": "Site content", - "hubEngagement": "Engagement", - "hubUsers": "Users", - "hubAccess": "Access & security", - "hubCatalog": "Catalog & rares", - "hubShop": "Shop & payments", - "hubTools": "Tools", - "hubObservability": "Observability", + "hubNewsMedia": "Nieuws en media", + "hubSiteContent": "Website-inhoud", + "hubEngagement": "Communityactiviteiten", + "hubUsers": "Gebruikers", + "hubAccess": "Toegang en beveiliging", + "hubCatalog": "Catalogus en zeldzame meubels", + "hubShop": "Winkel en betalingen", + "hubTools": "Hulpmiddelen", + "hubObservability": "Bewaking en diagnostiek", "community": "Community", - "monitoring": "Monitoring", + "monitoring": "Bewaking", "staffAccess": "Rangen & rechten", - "multiAccounts": "Multi-accounts", + "multiAccounts": "Gekoppelde accounts", "chatLog": "Chatlog", "commandLog": "Commandolog", "tradeLog": "Tradelog", - "tools": "Tools", - "itemsBase": "Items base", + "tools": "Hulpmiddelen", + "itemsBase": "Meubeldefinities", "adminMenu": "Adminmenu", - "studio": "Studio" + "studio": "Studio", + "peopleSupport": "Gebruikers en ondersteuning", + "catalogFurniture": "Catalogus en meubels", + "communityContent": "Community en inhoud", + "staffPermissions": "Medewerkers en toegang", + "cmsErrors": "CMS-fouten", + "themeBuilder": "Thema-editor", + "openSidebar": "Menu openen", + "closeSidebar": "Menu sluiten", + "search": { + "placeholder": "Zoeken…", + "noResults": "Geen resultaten gevonden", + "users": "Gebruikers", + "articles": "Artikelen", + "rooms": "Kamers", + "guilds": "Groepen", + "shop": "Winkel", + "hint": "Typ om te zoeken naar gebruikers, artikelen, kamers, groepen en winkelartikelen." + } }, "dashboard": { "title": "Dashboard", @@ -1153,7 +1171,12 @@ "restoreRevision": "Herstellen in editor", "confirmRevision": "De huidige inhoud vervangen door deze vorige versie als concept? Controleer de inhoud en kies een publicatiestatus voordat je opslaat.", "editConflict": "Dit artikel is gewijzigd sinds je het hebt geopend. Je wijzigingen zijn niet toegepast. Kopieer je werk, herlaad het artikel en voeg de wijzigingen samen voordat je opnieuw opslaat.", - "retryRecovery": "Herstelgegevens opnieuw laden zonder dit formulier te wijzigen" + "retryRecovery": "Herstelgegevens opnieuw laden zonder dit formulier te wijzigen", + "statusDraft": "Concept", + "statusScheduled": "Ingepland", + "statusPublished": "Gepubliceerd", + "publishAt": "Publiceren op", + "publishedAt": "Gepubliceerd op" }, "listActionError": "De actie voor dit artikel kon niet worden voltooid.", "listStatus": "Publicatiestatus", @@ -1172,7 +1195,11 @@ "listPagination": "Artikelpagina’s", "listPrevious": "Vorige", "listNext": "Volgende", - "listPage": "Pagina {page} van {total}" + "listPage": "Pagina {page} van {total}", + "articlesShown": "Getoonde artikelen", + "latest": "Laatste artikel", + "mostRecentPost": "Meest recente publicatie", + "recentArticles": "Recente artikelen" }, "events": { "newEvent": "+ Nieuw evenement", @@ -1472,7 +1499,7 @@ "registered": "Geregistreerd", "lastOnline": "Laatst online", "ipAddress": "IP-adres", - "machineId": "Machine ID", + "machineId": "Apparaat-ID", "sendAlert": "Melding sturen", "sendMessage": "Bericht sturen", "disconnectUser": "Verbinding verbreken", @@ -1480,11 +1507,11 @@ "resetPassword": "Wachtwoord resetten", "deleteUser": "Gebruiker verwijderen", "confirmDelete": "Weet je zeker dat je {username} wilt verwijderen? Dit kan niet ongedaan worden gemaakt.", - "tabInfo": "Account Info", + "tabInfo": "Accountgegevens", "tabBadges": "Badges ({count})", - "tabRooms": "Rooms ({count})", - "tabSanctions": "Sanctions ({count})", - "tabActivity": "Activity" + "tabRooms": "Kamers ({count})", + "tabSanctions": "Sancties ({count})", + "tabActivity": "Activiteit" }, "editForm": { "title": "Gebruiker bewerken: {username}", @@ -1502,6 +1529,84 @@ "save": "Wijzigingen opslaan", "saving": "Opslaan…", "cancel": "Annuleren" + }, + "presentation": { + "users": "Gebruikers", + "edit": "Bewerken", + "view": "Bekijken", + "rank": "Rang {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Verbannen", + "credits": "credits", + "diamonds": "diamanten", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Kamers", + "messages": "Berichten", + "trades": "Ruiltransacties", + "accountDetails": "Accountgegevens", + "registered": "Geregistreerd", + "lastLogin": "Laatste login", + "ipRegister": "Registratie-IP", + "ipCurrent": "Huidig IP", + "machineId": "Apparaat-ID", + "email": "E-mail", + "gender": "Geslacht", + "never": "Nooit", + "findIp": "Gebruikers met dit IP zoeken", + "enabled": "Ingeschakeld", + "disabled": "Uitgeschakeld", + "unavailable": "Niet beschikbaar", + "noBadges": "Geen badges toegewezen" + }, + "create": { + "newUser": "Nieuwe gebruiker", + "title": "Gebruiker aanmaken", + "description": "Maakt het account direct aan met een standaardavatar en 5.000 credits, pixels, duckets en diamanten.", + "username": "Gebruikersnaam", + "email": "E-mail", + "password": "Wachtwoord", + "rank": "Rang", + "rankOption": "Rang {rank}", + "motto": "Motto (optioneel)", + "usernamePlaceholder": "NieuweHabbo", + "passwordPlaceholder": "Minimaal 6 tekens", + "mottoPlaceholder": "Ik ben hier nieuw!", + "generate": "Genereren", + "passwordGenerated": "Wachtwoord gegenereerd in het wachtwoordveld.", + "required": "Gebruikersnaam, e-mail en wachtwoord zijn verplicht.", + "invalidUsername": "Gebruik 3–20 tekens: letters, cijfers, _ of -.", + "passwordLength": "Het wachtwoord moet minimaal 6 tekens bevatten.", + "created": "Gebruiker {username} aangemaakt." + }, + "overview": { + "title": "Overzicht", + "hint": "Maximaal 5 recente registraties per bron. Alleen onderdelen waarvoor je toegang hebt worden getoond.", + "restricted": "Je hebt geen toestemming om operationele gegevens te bekijken.", + "sanctions": "Recente bans", + "mute": "Actieve mute", + "tickets": "Supporttickets", + "help": "Helpcentrumverzoeken", + "reports": "Meldingen in het spel", + "transactions": "Betalingen", + "purchases": "Catalogusaankopen", + "activity": "Geregistreerde stafacties", + "openModule": "Beheer openen", + "purchaseHint": "Geregistreerde aantallen en valutakosten.", + "purchaseDetail": "Aantal: {amount} · Credits: {credits} · Punten: {points} (type {type})", + "activityHint": "Staflogboekregels over dit account. Dit is geen volledige geschiedenis van de activiteit van de speler.", + "muteUntil": "Gedempt tot {date}", + "error": "Deze bron kon niet worden geladen. Vernieuw de pagina om het opnieuw te proberen.", + "empty": "Geen registraties gevonden.", + "status": { + "active": "Actief", + "expired": "Verlopen", + "open": "Open", + "closed": "Gesloten", + "reported": "Gemelde gebruiker", + "reporter": "Melder" + } } }, "bans": { @@ -3123,7 +3228,7 @@ "radioSubtitle": "Stream, DJ-tools, embed en monitoring", "settings": "Instellingen", "settingsSubtitle": "CMS-configuratie, thema, onderhoud en gerelateerde tools", - "tools": "Tools", + "tools": "Hulpmiddelen", "toolsSubtitle": "RCON, kamers, import en vertalingen", "observability": "Observeerbaarheid", "observabilitySubtitle": "Logs, analytics, DevOps en hotelmeldingen", @@ -3134,10 +3239,10 @@ "ads": "Advertenties", "media": "Media", "navigator": "Navigator", - "help": "Help", + "help": "Ondersteuning", "boxes": "Boxen", "tags": "Tags", - "prefixes": "Prefixes", + "prefixes": "Voorvoegsels", "events": "Evenementen", "eventTypes": "Evenementtypen", "polls": "Polls", @@ -3145,21 +3250,21 @@ "helpTickets": "Helpcentrum", "templates": "Sjablonen", "directory": "Adresboek", - "multiAccounts": "Multi-accounts", + "multiAccounts": "Gekoppelde accounts", "online": "Online", "applications": "Sollicitaties", "teams": "Teams", - "bans": "Bans", + "bans": "Verbanningen", "ip": "IP", "vpn": "VPN", "permissions": "Permissies", "wordFilter": "Woordfilter", "overview": "Overzicht", "actions": "Acties", - "cfh": "CFH", + "cfh": "Moderatieverzoeken", "team": "Team", "catalogTab": "Catalogus", - "rares": "Rares", + "rares": "Zeldzame meubels", "badges": "Badges", "achievements": "Prestaties", "sounds": "Geluiden", @@ -3170,9 +3275,9 @@ "calendar": "Kalender", "history": "Geschiedenis", "moderationTab": "Moderatie", - "monitoring": "Monitoring", + "monitoring": "Bewaking", "settingsTab": "Instellingen", - "embed": "Embed", + "embed": "Insluiten", "apiKeys": "API-sleutels", "points": "Punten", "ranks": "Rangen", @@ -3185,35 +3290,36 @@ "emulator": "Emulator", "email": "E-mail", "housekeeping": "Permissie-archief", - "commando": "Commando", + "commando": "Operationeel centrum", "rooms": "Kamers", - "import": "Import", + "import": "Importeren", "studio": "Studio", "importOverview": "Overzicht", "importBadges": "Badges", "importFurni": "Meubels", - "importUpload": "Upload", + "importUpload": "Uploaden", "importClothing": "Kleding", "importEffects": "Effecten", "importPets": "Huisdieren", "importClone": "Kloon", - "importSync": "Sync", + "importSync": "Synchronisatie", "importRepairIcons": "Pictogrammen repareren", - "importAudit": "Audit", + "importAudit": "Gegevenscontrole", "translations": "Vertalingen", - "logs": "Logs", - "audit": "Audit", + "logs": "Logboeken", + "audit": "Wijzigingslogboek", "chat": "Chat", - "commands": "Commands", - "trades": "Trades", - "analytics": "Analytics", + "commands": "Commando's", + "trades": "Ruiltransacties", + "analytics": "Statistieken", "activity": "Activiteit", "economy": "Economie", "devops": "DevOps", "errors": "Fouten", "alerts": "Meldingen", - "toolsGroup": "Tools", - "ticketInbox": "Inbox" + "toolsGroup": "Hulpmiddelen", + "ticketInbox": "Ontvangen tickets", + "cmsErrors": "CMS-fouten" }, "events": "Evenementen", "eventsSubtitle": "Hotel-evenementen en evenementtypes", @@ -3221,11 +3327,11 @@ "ticketsSubtitle": "Twee aparte wachtrijen: CMS support desk en publieke helpcenter-tickets", "staffAccess": "Rangen & rechten", "staffAccessSubtitle": "Staffrangen en live toegang. Archief is alleen vergelijken/exporteren.", - "analytics": "Analytics", + "analytics": "Statistieken", "analyticsSubtitle": "Hotelactiviteit, retentie en economie", "devops": "DevOps", "devopsSubtitle": "Servicegezondheid en emulatorfouten", - "import": "Import", + "import": "Importeren", "importSubtitle": "Meubels, badges, kleding en asset-tools" }, "tickets": { @@ -3275,19 +3381,19 @@ "actionsTitle": "Acties", "requesterTitle": "Aanvrager", "detailsTitle": "Details", - "rankLabel": "Rank {rank}", + "rankLabel": "Rang {rank}", "messageCountLabel": "Berichten", "success": { "replied": "Antwoord verzonden.", "closed": "Ticket gesloten.", "reopened": "Ticket heropend.", - "banLifted": "Ban lifted and ticket closed." + "banLifted": "Verbanning opgeheven en ticket gesloten." }, "queueHint": "Deze wachtrij is het publieke helpcenter. CMS support desk tickets staan onder Supportbalie.", "queueOtherCta": "Open CMS support desk", "queueCounts": "Open hier: {here} · Supportbalie: {other}", - "liftBanSubmit": "Lift requester ban & close", - "requesterBanned": "Requester banned" + "liftBanSubmit": "Verbanning van aanvrager opheffen en sluiten", + "requesterBanned": "Aanvrager is verbannen" }, "cfh": { "title": "Call for Help", @@ -3583,6 +3689,25 @@ "specialPricing": "Speciale prijs: {credits} credits, {points} punten (type {type})", "restrictedOffers": "{count} aanbiedingen in deze categorie vereisen een Club-lidmaatschap.", "clubOffer": "Club-aanbieding" + }, + "actions": { + "saved": "Wijzigingen opgeslagen.", + "error": "De bewerking kon niet worden voltooid.", + "saving": "Bezig met opslaan…", + "saveError": "Opslaan mislukt. Je wijzigingen zijn behouden; probeer het opnieuw.", + "unsaved": "Niet-opgeslagen wijzigingen", + "leaveUnsaved": "Je hebt niet-opgeslagen wijzigingen. Wil je deze pagina verlaten?", + "preview": "Voorbeeld" + }, + "search": { + "furniture": "Meubels", + "catalog": "Cataloguscategorieën", + "tickets": "Supporttickets", + "loading": "Zoeken…", + "hint": "Voer een numeriek ID of minstens 2 tekens in, of kies een pagina.", + "retry": "Opnieuw proberen", + "description": "Zoek pagina’s, gebruikers, meubels, cataloguscategorieën, artikelen en supporttickets.", + "partialError": "Sommige resultaten konden niet worden geladen. Probeer opnieuw." } }, "mod": { diff --git a/src/messages/no.json b/src/messages/no.json index b96296b6..4f6114f7 100644 --- a/src/messages/no.json +++ b/src/messages/no.json @@ -1232,6 +1232,84 @@ "save": "Lagre endringer", "saving": "Lagrer …", "cancel": "Avbryt" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3237,6 +3315,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "error": { diff --git a/src/messages/pl.json b/src/messages/pl.json index 1b37fca5..6cfedbf6 100644 --- a/src/messages/pl.json +++ b/src/messages/pl.json @@ -1233,6 +1233,84 @@ "save": "Zapisz zmiany", "saving": "Zapisuję…", "cancel": "Anuluj" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3238,6 +3316,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "error": { diff --git a/src/messages/pt.json b/src/messages/pt.json index f03f59f9..e02441c6 100644 --- a/src/messages/pt.json +++ b/src/messages/pt.json @@ -1233,6 +1233,84 @@ "save": "Salvar alterações", "saving": "Salvando…", "cancel": "Cancelar" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3238,6 +3316,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "error": { diff --git a/src/messages/ro.json b/src/messages/ro.json index cbfbf851..919bc89b 100644 --- a/src/messages/ro.json +++ b/src/messages/ro.json @@ -1232,6 +1232,84 @@ "save": "Salvați modificările", "saving": "Se salvează…", "cancel": "Anulează" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3237,6 +3315,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "error": { diff --git a/src/messages/ru.json b/src/messages/ru.json index af6d892f..3f581f2f 100644 --- a/src/messages/ru.json +++ b/src/messages/ru.json @@ -1231,6 +1231,84 @@ "save": "Save changes", "saving": "Saving…", "cancel": "Cancel" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3236,6 +3314,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "error": { diff --git a/src/messages/sk.json b/src/messages/sk.json index 8404bd9f..9019a44e 100644 --- a/src/messages/sk.json +++ b/src/messages/sk.json @@ -1232,6 +1232,84 @@ "save": "Uložiť zmeny", "saving": "Ukladá sa…", "cancel": "Zrušiť" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3237,6 +3315,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "error": { diff --git a/src/messages/sr.json b/src/messages/sr.json index 4950932f..a0e62297 100644 --- a/src/messages/sr.json +++ b/src/messages/sr.json @@ -1232,6 +1232,84 @@ "save": "Сачувај промене", "saving": "Чување…", "cancel": "Откажи" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3237,6 +3315,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "error": { diff --git a/src/messages/sv.json b/src/messages/sv.json index 99a1d8f8..4ac97319 100644 --- a/src/messages/sv.json +++ b/src/messages/sv.json @@ -1232,6 +1232,84 @@ "save": "Spara ändringar", "saving": "Sparar...", "cancel": "Avbryt" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3237,6 +3315,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "error": { diff --git a/src/messages/tr.json b/src/messages/tr.json index f2e7fb70..acbf40d8 100644 --- a/src/messages/tr.json +++ b/src/messages/tr.json @@ -1233,6 +1233,84 @@ "save": "Değişiklikleri kaydet", "saving": "Kaydediliyor…", "cancel": "İptal" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3238,6 +3316,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "error": { diff --git a/src/messages/uk.json b/src/messages/uk.json index d614d2a9..77cd619b 100644 --- a/src/messages/uk.json +++ b/src/messages/uk.json @@ -1232,6 +1232,84 @@ "save": "Зберегти зміни", "saving": "Збереження…", "cancel": "Скасувати" + }, + "presentation": { + "users": "Users", + "edit": "Edit", + "view": "View", + "rank": "Rank {rank}", + "online": "Online", + "offline": "Offline", + "banned": "Banned", + "credits": "credits", + "diamonds": "diamonds", + "duckets": "duckets", + "pixels": "pixels", + "rooms": "Rooms", + "messages": "Messages", + "trades": "Trades", + "accountDetails": "Account details", + "registered": "Registered", + "lastLogin": "Last login", + "ipRegister": "Registration IP", + "ipCurrent": "Current IP", + "machineId": "Device ID", + "email": "Email", + "gender": "Gender", + "never": "Never", + "findIp": "Find users with this IP", + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Not available", + "noBadges": "No badges assigned" + }, + "create": { + "newUser": "New user", + "title": "Create user", + "description": "Creates the account immediately with a default avatar and 5,000 credits, pixels, duckets and diamonds.", + "username": "Username", + "email": "Email", + "password": "Password", + "rank": "Rank", + "rankOption": "Rank {rank}", + "motto": "Motto (optional)", + "usernamePlaceholder": "NewHabbo", + "passwordPlaceholder": "At least 6 characters", + "mottoPlaceholder": "I'm new here!", + "generate": "Generate", + "passwordGenerated": "Password generated in the password field.", + "required": "Username, email and password are required.", + "invalidUsername": "Use 3–20 characters: letters, numbers, _ or -.", + "passwordLength": "The password must contain at least 6 characters.", + "created": "User {username} created." + }, + "overview": { + "title": "Overview", + "hint": "Up to 5 recent records per source. Only sections you can access are shown.", + "restricted": "You do not have permission to view operational records.", + "sanctions": "Recent bans", + "mute": "Active mute", + "tickets": "Support tickets", + "help": "Help center requests", + "reports": "In-game reports", + "transactions": "Payments", + "purchases": "Catalog purchases", + "activity": "Recorded staff actions", + "openModule": "Open management", + "purchaseHint": "Recorded quantities and currency costs.", + "purchaseDetail": "Quantity: {amount} · Credits: {credits} · Points: {points} (type {type})", + "activityHint": "Staff audit entries targeting this account. This is not a complete history of player activity.", + "muteUntil": "Muted until {date}", + "error": "This source could not be loaded. Refresh the page to try again.", + "empty": "No records found.", + "status": { + "active": "Active", + "expired": "Expired", + "open": "Open", + "closed": "Closed", + "reported": "Reported user", + "reporter": "Reporter" + } } }, "bans": { @@ -3237,6 +3315,16 @@ "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", "restrictedOffers": "{count} offers in this category require Club membership.", "clubOffer": "Club offer" + }, + "search": { + "furniture": "Furniture", + "catalog": "Catalog categories", + "tickets": "Support tickets", + "loading": "Searching…", + "hint": "Enter a numeric ID or at least 2 characters, or choose a page.", + "retry": "Retry", + "description": "Search pages, users, furniture, catalog categories, articles and support tickets.", + "partialError": "Some results could not be loaded. Try again." } }, "error": {