Improve admin UX: hub i18n, dynamic topbar, settings strings, staff logs DataTable.
Local Build and Deploy / deploy (push) Successful in 1m12s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-17 20:14:55 +02:00
1 parent 604e63da51
commit 1039044e48
11 files changed
+526 -305

No files matched your search

+2 -34
View File
@@ -6,6 +6,7 @@ import type { ReactNode } from "react";
import { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper";
import { AdminNavLink } from "@/components/admin/admin-nav-link";
import { AdminTopbar } from "@/components/admin/admin-topbar";
import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { requireStaff } from "@/lib/admin/guard";
@@ -38,7 +39,7 @@ export default async function AdminLayout({
className="flex flex-col min-w-0 p-5 lg:p-6 min-h-screen"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<Topbar staff={staff} />
<AdminTopbar staff={staff} />
<section className="admin-page flex-1">
<AdminHubChrome>{children}</AdminHubChrome>
</section>
@@ -128,36 +129,3 @@ async function Sidebar({
);
}
async function Topbar({
staff,
}: {
staff: { id: number; username: string; rank: number };
}) {
const t = await getTranslations("pages.admin.nav");
return (
<header className="flex items-center justify-between gap-4 bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl px-5 py-3 mb-5 shadow-card">
<div className="flex items-center gap-3">
<h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]">
{t("housekeeping")}
</h2>
<span className="hidden sm:inline-block w-px h-5 bg-[var(--admin-border)]" />
<span className="hidden sm:inline text-xs text-[var(--admin-text-muted)]">
{t("hotelManagementPanel")}
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 lg:hidden">
<LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" />
</div>
<span className="text-xs text-[var(--admin-text-muted)]">
<strong className="text-[var(--admin-text)]">{staff.username}</strong>
</span>
<span className="inline-flex items-center gap-1.5 text-[0.6rem] font-bold uppercase tracking-wider text-[var(--admin-accent-foreground)] bg-[var(--admin-accent)] rounded-full px-2.5 py-1">
{t("rankLabel", { rank: staff.rank })}
</span>
</div>
</header>
);
}
+58 -149
View File
@@ -1,28 +1,28 @@
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { StatusCard } from "@/components/admin/dashboard";
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { StaffActivitiesTable } from "./staff-activities-table";
export const dynamic = "force-dynamic";
const PER_PAGE = 25;
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
const SORT_MAP: Record<string, "createdAt" | "userId" | "action"> = {
when: "createdAt",
staff: "userId",
action: "action",
};
export default async function AdminStaffActivities({
searchParams,
}: {
searchParams: Promise<{
q?: string;
page?: string;
staffId?: string;
action?: string;
authorization?: string;
}>;
searchParams: Promise<Record<string, string>>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
@@ -30,20 +30,22 @@ export default async function AdminStaffActivities({
}
const t = await getTranslations("pages.admin.logs");
const sp = await searchParams;
const q = (sp.q ?? "").trim();
const page = Math.max(1, Number(sp.page ?? "1") || 1);
const staffId = sp.staffId ? Number(sp.staffId) : null;
const action = sp.action ?? null;
const authorizationOnly = sp.authorization === "1";
const raw = await searchParams;
const parsed = parseListParams(new URLSearchParams(raw));
const authorizationOnly = raw.filter_authorization === "1";
const whereClause = buildStaffActivityWhere({
q,
staffId,
action,
q: parsed.search,
authorizationOnly,
});
const sortField = SORT_MAP[parsed.sort ?? ""] ?? "createdAt";
const total = await prisma.staffActivities
.count({ where: whereClause })
.catch(() => 0);
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const activities = await prisma.staffActivities
.findMany({
where: whereClause,
@@ -57,9 +59,9 @@ export default async function AdminStaffActivities({
ipAddress: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
skip: (page - 1) * PER_PAGE,
take: PER_PAGE,
orderBy: { [sortField]: parsed.order },
skip: pagination.offset,
take: pagination.perPage,
})
.catch(() => []);
@@ -77,19 +79,29 @@ export default async function AdminStaffActivities({
nameById = new Map(users.map((x) => [x.id, x.username]));
}
const total = await prisma.staffActivities
.count({ where: whereClause })
.catch(() => 0);
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
const onlineCount = activities.filter(
(a) => a.createdAt && new Date(a.createdAt).getTime() > Date.now() - 60000,
const recentCount = activities.filter(
(a) => a.createdAt && new Date(a.createdAt).getTime() > Date.now() - 60_000,
).length;
const rows = activities.map((a) => ({
id: Number(a.id),
when: fromDate(a.createdAt),
staff: t("staffWithId", {
id: Number(a.userId),
name: nameById.get(Number(a.userId)) ?? "unknown",
}),
action: a.action,
description: a.description,
target:
a.targetType && a.targetId
? `${a.targetType === "user" ? t("userPrefix") : a.targetType} #${String(a.targetId)}`
: "—",
ip: a.ipAddress || "—",
}));
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<div className="space-y-6">
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
<StatusCard label={t("totalActivities")} value={total} icon="📋" />
<StatusCard
label={t("onThisPage")}
@@ -98,124 +110,21 @@ export default async function AdminStaffActivities({
/>
<StatusCard
label={t("recent")}
value={onlineCount}
state={onlineCount > 0 ? "ok" : "neutral"}
value={recentCount}
state={recentCount > 0 ? "ok" : "neutral"}
icon="🟢"
/>
</div>
<form className="admin-card flex gap-2 mb-6 flex-wrap">
<input
name="q"
defaultValue={q}
placeholder={t("searchPlaceholder")}
className="flex-1 min-w-[200px]"
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="authorization"
value="1"
defaultChecked={authorizationOnly}
/>
Authorization errors
</label>
<input
name="staffId"
defaultValue={staffId?.toString()}
placeholder={t("staffId")}
className="w-[120px]"
/>
<input
name="action"
defaultValue={action ?? ""}
placeholder={t("actionType")}
className="w-[160px]"
/>
<button type="submit" className="btn btn-primary">
{t("filter")}
</button>
{(q || staffId || action || authorizationOnly) && (
<a href="/admin/logs" className="btn btn-outline">
{t("clearFilters")}
</a>
)}
</form>
<section className="mt-6">
<h2 className="admin-section-title">
{t("activities", { count: total })}
</h2>
{activities.length === 0 ? (
<div className="admin-empty">{t("noResults")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colWhen")}</th>
<th>{t("colStaff")}</th>
<th>{t("colAction")}</th>
<th>{t("colDescription")}</th>
<th>{t("colTarget")}</th>
<th>{t("colIp")}</th>
</tr>
</thead>
<tbody>
{activities.map((a) => (
<tr key={String(a.id)}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{fromDate(a.createdAt)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{t("staffWithId", {
id: Number(a.userId),
name: nameById.get(Number(a.userId)) ?? "unknown",
})}
</td>
<td>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{a.action}
</span>
</td>
<td>{a.description}</td>
<td>
{a.targetType && a.targetId ? (
<span className="text-sm theme-text-muted dark:theme-text-muted">
{a.targetType === "user" ? t("userPrefix") : ""}#
{String(a.targetId)}
</span>
) : (
<span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{a.ipAddress || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<p className="text-xs theme-text-muted dark:theme-text-muted">
{t("pagination", { page, pages, total })}
{page < pages ? (
<>
{" · "}
<a
href={`/admin/logs?q=${encodeURIComponent(q)}&staffId=${staffId || ""}&action=${action || ""}&authorization=${authorizationOnly ? "1" : ""}&page=${page + 1}`}
>
{t("next")} →
</a>
</>
) : null}
</p>
</main>
<StaffActivitiesTable
data={{
rows,
total,
page: pagination.page,
perPage: pagination.perPage,
lastPage: pagination.lastPage,
}}
/>
</div>
);
}
@@ -0,0 +1,56 @@
"use client";
import { useTranslations } from "next-intl";
import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge";
import type { DataTableColumn, PaginatedResult } from "@/types";
export interface StaffActivityRow {
id: number;
when: string;
staff: string;
action: string;
description: string;
target: string;
ip: string;
}
export function StaffActivitiesTable({
data,
}: {
data: PaginatedResult<StaffActivityRow>;
}) {
const t = useTranslations("pages.admin.logs");
const columns: DataTableColumn<StaffActivityRow>[] = [
{ key: "when", label: t("colWhen"), sortable: true },
{ key: "staff", label: t("colStaff"), sortable: true },
{
key: "action",
label: t("colAction"),
sortable: true,
render: (value) => (
<Badge variant="secondary" className="font-mono text-[0.7rem]">
{String(value)}
</Badge>
),
},
{ key: "description", label: t("colDescription") },
{ key: "target", label: t("colTarget") },
{ key: "ip", label: t("colIp") },
];
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder={t("searchPlaceholder")}
presets={[
{
label: t("authorizationErrors"),
params: { filter_authorization: "1" },
},
]}
/>
);
}
+6 -6
View File
@@ -11,6 +11,7 @@ import {
Shield,
UserPlus,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { saveManagedSettings } from "@/actions/admin-settings";
import { Button } from "@/components/ui/button";
@@ -45,6 +46,7 @@ export function CmsSettingsForm({
}) {
const [values, setValues] = useState(initialValues);
const { isPending, run } = useServerAction();
const t = useTranslations("pages.admin.settings");
function handleChange(key: string, value: string) {
setValues((prev) => ({ ...prev, [key]: value }));
@@ -58,8 +60,8 @@ export function CmsSettingsForm({
normalized[key] = values[key] === "1" ? "1" : "0";
}
run(() => saveManagedSettings({ settings: normalized }), {
successMessage: "Settings saved.",
errorMessage: "Could not save settings.",
successMessage: t("saved"),
errorMessage: t("saveFailed"),
});
}
@@ -83,13 +85,11 @@ export function CmsSettingsForm({
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save settings
{isPending ? t("form.saving") : t("saveSettings")}
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground">
You can view settings but need edit permission to change them.
</p>
<p className="text-sm text-muted-foreground">{t("viewOnly")}</p>
)}
</form>
);
+7 -3
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
@@ -18,6 +19,7 @@ export default async function AdminSettings() {
PERMS.SETTINGS_EDIT,
session.user.rank,
);
const t = await getTranslations("pages.admin.settings");
let settings: Awaited<ReturnType<typeof prisma.websiteSetting.findMany>> = [];
try {
@@ -42,12 +44,14 @@ export default async function AdminSettings() {
<div className="space-y-8">
<div className="admin-stat-row">
<span className="admin-stat-chip">
{settings.length} keys in database
{t("keysInDatabase", { count: settings.length })}
</span>
<span className="admin-stat-chip">
{MANAGED_SETTING_KEYS.length} managed in form
{t("managedInForm", { count: MANAGED_SETTING_KEYS.length })}
</span>
<span className="admin-stat-chip">
{t("documented", { count: documented })}
</span>
<span className="admin-stat-chip">{documented} documented</span>
</div>
<CmsSettingsForm values={values} canEdit={canEdit} />
+12 -3
View File
@@ -1,6 +1,7 @@
"use client";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import type { ReactNode } from "react";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { AdminSectionTabs } from "@/components/admin/admin-section-tabs";
@@ -13,15 +14,23 @@ import { findAdminHub } from "@/lib/admin-nav";
export function AdminHubChrome({ children }: { children: ReactNode }) {
const pathname = usePathname() ?? "";
const hub = findAdminHub(pathname);
const t = useTranslations("pages.admin.hubs");
if (!hub) return <>{children}</>;
const tabs = hub.tabs.map((tab) => ({
href: tab.href,
label: t(`tabs.${tab.labelKey}`),
match: tab.match,
group: tab.group,
}));
return (
<AdminPageShell
icon={hub.icon}
title={hub.title}
subtitle={hub.subtitle}
tabs={<AdminSectionTabs tabs={hub.tabs} />}
title={t(hub.titleKey)}
subtitle={t(hub.subtitleKey)}
tabs={<AdminSectionTabs tabs={tabs} />}
className="admin-hub-panel"
>
{children}
+7 -10
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
export interface AdminTab {
@@ -13,11 +14,6 @@ export interface AdminTab {
group?: string;
}
const GROUP_LABELS: Record<string, string> = {
primary: "",
tools: "Tools",
};
function matchLength(pathname: string, prefix: string): number {
if (pathname === prefix) return prefix.length + 1; // prefer exact
if (pathname.startsWith(`${prefix}/`)) return prefix.length;
@@ -71,6 +67,7 @@ function TabRow({
export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
const pathname = usePathname() ?? "";
const t = useTranslations("pages.admin.hubs");
let bestIndex = -1;
let bestScore = -1;
@@ -87,9 +84,7 @@ export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
const hasGroups = tabs.some((t) => t.group);
if (!hasGroups) {
return (
<TabRow tabs={tabs} bestIndex={bestIndex} globalOffset={0} />
);
return <TabRow tabs={tabs} bestIndex={bestIndex} globalOffset={0} />;
}
const order: string[] = [];
@@ -102,16 +97,18 @@ export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
return (
<div className="space-y-3">
{order.map((groupId) => {
const groupTabs = tabs.filter((t) => (t.group ?? "primary") === groupId);
const groupTabs = tabs.filter((tab) => (tab.group ?? "primary") === groupId);
const rowOffset = offset;
offset += groupTabs.length;
const label =
groupId === "tools" ? t("tabs.toolsGroup") : undefined;
return (
<TabRow
key={groupId}
tabs={groupTabs}
bestIndex={bestIndex}
globalOffset={rowOffset}
label={GROUP_LABELS[groupId] || undefined}
label={label}
/>
);
})}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { findAdminHub } from "@/lib/admin-nav";
export function AdminTopbar({
staff,
}: {
staff: { id: number; username: string; rank: number };
}) {
const tNav = useTranslations("pages.admin.nav");
const tHubs = useTranslations("pages.admin.hubs");
const pathname = usePathname() ?? "";
const hub = findAdminHub(pathname);
const title = hub ? tHubs(hub.titleKey) : tNav("housekeeping");
return (
<header className="flex items-center justify-between gap-4 bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl px-5 py-3 mb-5 shadow-card">
<div className="flex items-center gap-3">
<h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]">
{title}
</h2>
<span className="hidden sm:inline-block w-px h-5 bg-[var(--admin-border)]" />
<span className="hidden sm:inline text-xs text-[var(--admin-text-muted)]">
{tNav("hotelManagementPanel")}
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 lg:hidden">
<LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" />
</div>
<span className="text-xs text-[var(--admin-text-muted)]">
<strong className="text-[var(--admin-text)]">{staff.username}</strong>
</span>
<span className="inline-flex items-center gap-1.5 text-[0.6rem] font-bold uppercase tracking-wider text-[var(--admin-accent-foreground)] bg-[var(--admin-accent)] rounded-full px-2.5 py-1">
{tNav("rankLabel", { rank: staff.rank })}
</span>
</div>
</header>
);
}
+101 -99
View File
@@ -19,7 +19,8 @@ import {
export interface AdminHubTab {
href: string;
label: string;
/** Key under pages.admin.hubs.tabs.* */
labelKey: string;
match?: string[];
/** Optional group id for multi-row tab strips (e.g. Radio primary/tools). */
group?: string;
@@ -27,8 +28,9 @@ export interface AdminHubTab {
export interface AdminHubDefinition {
id: string;
title: string;
subtitle: string;
/** Key under pages.admin.hubs.* for title/subtitle */
titleKey: string;
subtitleKey: string;
icon: LucideIcon;
/** Path prefixes belonging to this hub (for chrome + sidebar active). */
prefixes: string[];
@@ -52,8 +54,8 @@ export interface AdminNavGroup {
export const ADMIN_HUBS: AdminHubDefinition[] = [
{
id: "news-media",
title: "News & media",
subtitle: "Articles, photos, banners, ads and media library",
titleKey: "newsMedia",
subtitleKey: "newsMediaSubtitle",
icon: Newspaper,
prefixes: [
"/admin/articles",
@@ -65,19 +67,19 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
tabs: [
{
href: "/admin/articles",
label: "Articles",
labelKey: "articles",
match: ["/admin/articles"],
},
{ href: "/admin/photos", label: "Photos" },
{ href: "/admin/banners", label: "Banners" },
{ href: "/admin/ads", label: "Ads", match: ["/admin/ads"] },
{ href: "/admin/media", label: "Media" },
{ href: "/admin/photos", labelKey: "photos" },
{ href: "/admin/banners", labelKey: "banners" },
{ href: "/admin/ads", labelKey: "ads", match: ["/admin/ads"] },
{ href: "/admin/media", labelKey: "media" },
],
},
{
id: "site-content",
title: "Site content",
subtitle: "Navigation, help center, boxes, tags and prefixes",
titleKey: "siteContent",
subtitleKey: "siteContentSubtitle",
icon: Compass,
prefixes: [
"/admin/navigation",
@@ -87,39 +89,39 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
"/admin/prefixes",
],
tabs: [
{ href: "/admin/navigation", label: "Navigator" },
{ href: "/admin/navigation", labelKey: "navigator" },
{
href: "/admin/help-questions",
label: "Help",
labelKey: "help",
match: ["/admin/help-questions"],
},
{ href: "/admin/writeable-boxes", label: "Boxes" },
{ href: "/admin/tags", label: "Tags" },
{ href: "/admin/prefixes", label: "Prefixes" },
{ href: "/admin/writeable-boxes", labelKey: "boxes" },
{ href: "/admin/tags", labelKey: "tags" },
{ href: "/admin/prefixes", labelKey: "prefixes" },
],
},
{
id: "engagement",
title: "Engagement",
subtitle: "Events, polls and support tickets",
titleKey: "engagement",
subtitleKey: "engagementSubtitle",
icon: Calendar,
prefixes: ["/admin/events", "/admin/polls", "/admin/tickets"],
tabs: [
{ href: "/admin/events", label: "Events", match: ["/admin/events"] },
{ href: "/admin/events/types", label: "Event types" },
{ href: "/admin/polls", label: "Polls", match: ["/admin/polls"] },
{ href: "/admin/events", labelKey: "events", match: ["/admin/events"] },
{ href: "/admin/events/types", labelKey: "eventTypes" },
{ href: "/admin/polls", labelKey: "polls", match: ["/admin/polls"] },
{
href: "/admin/tickets",
label: "Tickets",
labelKey: "tickets",
match: ["/admin/tickets"],
},
{ href: "/admin/tickets/templates", label: "Templates" },
{ href: "/admin/tickets/templates", labelKey: "templates" },
],
},
{
id: "users",
title: "Users",
subtitle: "Directory, online players, applications and teams",
titleKey: "users",
subtitleKey: "usersSubtitle",
icon: Users,
prefixes: [
"/admin/users",
@@ -130,19 +132,19 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
tabs: [
{
href: "/admin/users",
label: "Directory",
labelKey: "directory",
match: ["/admin/users"],
},
{ href: "/admin/users/multi-accounts", label: "Multi-accounts" },
{ href: "/admin/online", label: "Online" },
{ href: "/admin/applications", label: "Applications" },
{ href: "/admin/teams", label: "Teams" },
{ href: "/admin/users/multi-accounts", labelKey: "multiAccounts" },
{ href: "/admin/online", labelKey: "online" },
{ href: "/admin/applications", labelKey: "applications" },
{ href: "/admin/teams", labelKey: "teams" },
],
},
{
id: "access",
title: "Access & security",
subtitle: "Bans, IP, VPN, permissions and word filter",
titleKey: "access",
subtitleKey: "accessSubtitle",
icon: Shield,
prefixes: [
"/admin/bans",
@@ -152,38 +154,38 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
"/admin/wordfilter",
],
tabs: [
{ href: "/admin/bans", label: "Bans" },
{ href: "/admin/ip", label: "IP" },
{ href: "/admin/vpn", label: "VPN" },
{ href: "/admin/bans", labelKey: "bans" },
{ href: "/admin/ip", labelKey: "ip" },
{ href: "/admin/vpn", labelKey: "vpn" },
{
href: "/admin/permissions",
label: "Permissions",
labelKey: "permissions",
match: ["/admin/permissions"],
},
{ href: "/admin/wordfilter", label: "Word filter" },
{ href: "/admin/wordfilter", labelKey: "wordFilter" },
],
},
{
id: "moderation",
title: "Moderation",
subtitle: "Reports, actions and moderation team",
titleKey: "moderation",
subtitleKey: "moderationSubtitle",
icon: Gavel,
prefixes: ["/admin/moderation"],
tabs: [
{ href: "/admin/moderation", label: "Overview" },
{ href: "/admin/moderation/actions", label: "Actions" },
{ href: "/admin/moderation", labelKey: "overview" },
{ href: "/admin/moderation/actions", labelKey: "actions" },
{
href: "/admin/moderation/cfh",
label: "CFH",
labelKey: "cfh",
match: ["/admin/moderation/cfh"],
},
{ href: "/admin/moderation/team", label: "Team" },
{ href: "/admin/moderation/team", labelKey: "team" },
],
},
{
id: "catalog",
title: "Catalog & rares",
subtitle: "Catalog, rares, badges, achievements and sounds",
titleKey: "catalog",
subtitleKey: "catalogSubtitle",
icon: Store,
prefixes: [
"/admin/catalog",
@@ -193,17 +195,17 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
"/admin/sounds",
],
tabs: [
{ href: "/admin/catalog", label: "Catalog", match: ["/admin/catalog"] },
{ href: "/admin/rare-values", label: "Rares" },
{ href: "/admin/badges", label: "Badges" },
{ href: "/admin/achievements", label: "Achievements" },
{ href: "/admin/sounds", label: "Sounds" },
{ href: "/admin/catalog", labelKey: "catalogTab", match: ["/admin/catalog"] },
{ href: "/admin/rare-values", labelKey: "rares" },
{ href: "/admin/badges", labelKey: "badges" },
{ href: "/admin/achievements", labelKey: "achievements" },
{ href: "/admin/sounds", labelKey: "sounds" },
],
},
{
id: "shop",
title: "Shop & payments",
subtitle: "Shop, transactions, vouchers, subscriptions and calendar",
titleKey: "shop",
subtitleKey: "shopSubtitle",
icon: ShoppingCart,
prefixes: [
"/admin/shop",
@@ -213,49 +215,49 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
"/admin/calendar",
],
tabs: [
{ href: "/admin/shop", label: "Shop" },
{ href: "/admin/transactions", label: "Transactions" },
{ href: "/admin/vouchers", label: "Vouchers" },
{ href: "/admin/subscriptions", label: "Subscriptions" },
{ href: "/admin/shop", labelKey: "shopTab" },
{ href: "/admin/transactions", labelKey: "transactions" },
{ href: "/admin/vouchers", labelKey: "vouchers" },
{ href: "/admin/subscriptions", labelKey: "subscriptions" },
{
href: "/admin/calendar",
label: "Calendar",
labelKey: "calendar",
match: ["/admin/calendar"],
},
],
},
{
id: "radio",
title: "Radio",
subtitle: "Stream, DJ tools, embed and monitoring",
titleKey: "radio",
subtitleKey: "radioSubtitle",
icon: Radio,
prefixes: ["/admin/radio"],
tabs: [
{ href: "/admin/radio", label: "Overview", group: "primary" },
{ href: "/admin/radio/history", label: "History", group: "primary" },
{ href: "/admin/radio", labelKey: "overview", group: "primary" },
{ href: "/admin/radio/history", labelKey: "history", group: "primary" },
{
href: "/admin/radio/moderation",
label: "Moderation",
labelKey: "moderationTab",
group: "primary",
},
{
href: "/admin/radio/monitoring",
label: "Monitoring",
labelKey: "monitoring",
group: "primary",
},
{ href: "/admin/radio/settings", label: "Settings", group: "primary" },
{ href: "/admin/radio/banners", label: "Banners", group: "tools" },
{ href: "/admin/radio/embed", label: "Embed", group: "tools" },
{ href: "/admin/radio/api-keys", label: "API keys", group: "tools" },
{ href: "/admin/radio/points", label: "Points", group: "tools" },
{ href: "/admin/radio/ranks", label: "Ranks", group: "tools" },
{ href: "/admin/radio/autodj", label: "Auto DJ", group: "tools" },
{ href: "/admin/radio/settings", labelKey: "settingsTab", group: "primary" },
{ href: "/admin/radio/banners", labelKey: "banners", group: "tools" },
{ href: "/admin/radio/embed", labelKey: "embed", group: "tools" },
{ href: "/admin/radio/api-keys", labelKey: "apiKeys", group: "tools" },
{ href: "/admin/radio/points", labelKey: "points", group: "tools" },
{ href: "/admin/radio/ranks", labelKey: "ranks", group: "tools" },
{ href: "/admin/radio/autodj", labelKey: "autoDj", group: "tools" },
],
},
{
id: "settings",
title: "Settings",
subtitle: "CMS config, theme, maintenance and related tools",
titleKey: "settings",
subtitleKey: "settingsSubtitle",
icon: Cog,
prefixes: [
"/admin/settings",
@@ -267,19 +269,19 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
"/admin/housekeeping",
],
tabs: [
{ href: "/admin/settings", label: "CMS" },
{ href: "/admin/theme", label: "Theme" },
{ href: "/admin/maintenance", label: "Maintenance" },
{ href: "/admin/favicon", label: "Favicon" },
{ href: "/admin/emulator", label: "Emulator" },
{ href: "/admin/email-templates", label: "Email" },
{ href: "/admin/housekeeping", label: "Housekeeping" },
{ href: "/admin/settings", labelKey: "cms" },
{ href: "/admin/theme", labelKey: "theme" },
{ href: "/admin/maintenance", labelKey: "maintenance" },
{ href: "/admin/favicon", labelKey: "favicon" },
{ href: "/admin/emulator", labelKey: "emulator" },
{ href: "/admin/email-templates", labelKey: "email" },
{ href: "/admin/housekeeping", labelKey: "housekeeping" },
],
},
{
id: "tools",
title: "Tools",
subtitle: "RCON, rooms, import and translations",
titleKey: "tools",
subtitleKey: "toolsSubtitle",
icon: Wrench,
prefixes: [
"/admin/commandocentrum",
@@ -288,20 +290,20 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
"/admin/translations",
],
tabs: [
{ href: "/admin/commandocentrum", label: "Commando" },
{ href: "/admin/rooms", label: "Rooms", match: ["/admin/rooms"] },
{ href: "/admin/import", label: "Import", match: ["/admin/import"] },
{ href: "/admin/commandocentrum", labelKey: "commando" },
{ href: "/admin/rooms", labelKey: "rooms", match: ["/admin/rooms"] },
{ href: "/admin/import", labelKey: "import", match: ["/admin/import"] },
{
href: "/admin/translations",
label: "Translations",
labelKey: "translations",
match: ["/admin/translations"],
},
],
},
{
id: "observability",
title: "Observability",
subtitle: "Logs, analytics, DevOps and hotel alerts",
titleKey: "observability",
subtitleKey: "observabilitySubtitle",
icon: Monitor,
prefixes: [
"/admin/logs",
@@ -310,21 +312,21 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
"/admin/alerts",
],
tabs: [
{ href: "/admin/logs", label: "Logs" },
{ href: "/admin/logs/audit", label: "Audit" },
{ href: "/admin/logs/chat", label: "Chat" },
{ href: "/admin/logs/commands", label: "Commands" },
{ href: "/admin/logs/trades", label: "Trades" },
{ href: "/admin/logs", labelKey: "logs" },
{ href: "/admin/logs/audit", labelKey: "audit" },
{ href: "/admin/logs/chat", labelKey: "chat" },
{ href: "/admin/logs/commands", labelKey: "commands" },
{ href: "/admin/logs/trades", labelKey: "trades" },
{
href: "/admin/analytics",
label: "Analytics",
labelKey: "analytics",
match: ["/admin/analytics"],
},
{ href: "/admin/analytics/activity", label: "Activity" },
{ href: "/admin/analytics/economy", label: "Economy" },
{ href: "/admin/devops", label: "DevOps", match: ["/admin/devops"] },
{ href: "/admin/devops/errors", label: "Errors" },
{ href: "/admin/alerts", label: "Alerts" },
{ href: "/admin/analytics/activity", labelKey: "activity" },
{ href: "/admin/analytics/economy", labelKey: "economy" },
{ href: "/admin/devops", labelKey: "devops", match: ["/admin/devops"] },
{ href: "/admin/devops/errors", labelKey: "errors" },
{ href: "/admin/alerts", labelKey: "alerts" },
],
},
];
+126 -1
View File
@@ -1811,16 +1811,134 @@
"title": "Staff Activities",
"subtitle": "Audit trail of staff actions in the admin panel",
"noLogs": "No staff activity logged yet",
"noResults": "No matching activities",
"colId": "ID",
"colUser": "User",
"colStaff": "Staff",
"colAction": "Action",
"colTarget": "Target",
"colDescription": "Description",
"colIp": "IP",
"colWhen": "When",
"colTimestamp": "Timestamp",
"filterByUser": "Filter by user…",
"filterByAction": "Filter by action…",
"clearFilters": "Clear filters"
"clearFilters": "Clear filters",
"searchPlaceholder": "Search action, description or IP…",
"totalActivities": "Total activities",
"onThisPage": "On this page",
"recent": "Last minute",
"authorizationErrors": "Authorization errors",
"authorizationOnly": "Auth errors only",
"allActivities": "All activities",
"staffId": "Staff ID",
"actionType": "Action type",
"filter": "Filter",
"activities": "{count} activities",
"staffWithId": "#{id} {name}",
"userPrefix": "user",
"pagination": "Page {page} of {pages} · {total} total",
"next": "Next",
"prev": "Previous"
},
"hubs": {
"newsMedia": "News & media",
"newsMediaSubtitle": "Articles, photos, banners, ads and media library",
"siteContent": "Site content",
"siteContentSubtitle": "Navigation, help center, boxes, tags and prefixes",
"engagement": "Engagement",
"engagementSubtitle": "Events, polls and support tickets",
"users": "Users",
"usersSubtitle": "Directory, online players, applications and teams",
"access": "Access & security",
"accessSubtitle": "Bans, IP, VPN, permissions and word filter",
"moderation": "Moderation",
"moderationSubtitle": "Reports, actions and moderation team",
"catalog": "Catalog & rares",
"catalogSubtitle": "Catalog, rares, badges, achievements and sounds",
"shop": "Shop & payments",
"shopSubtitle": "Shop, transactions, vouchers, subscriptions and calendar",
"radio": "Radio",
"radioSubtitle": "Stream, DJ tools, embed and monitoring",
"settings": "Settings",
"settingsSubtitle": "CMS config, theme, maintenance and related tools",
"tools": "Tools",
"toolsSubtitle": "RCON, rooms, import and translations",
"observability": "Observability",
"observabilitySubtitle": "Logs, analytics, DevOps and hotel alerts",
"tabs": {
"articles": "Articles",
"photos": "Photos",
"banners": "Banners",
"ads": "Ads",
"media": "Media",
"navigator": "Navigator",
"help": "Help",
"boxes": "Boxes",
"tags": "Tags",
"prefixes": "Prefixes",
"events": "Events",
"eventTypes": "Event types",
"polls": "Polls",
"tickets": "Tickets",
"templates": "Templates",
"directory": "Directory",
"multiAccounts": "Multi-accounts",
"online": "Online",
"applications": "Applications",
"teams": "Teams",
"bans": "Bans",
"ip": "IP",
"vpn": "VPN",
"permissions": "Permissions",
"wordFilter": "Word filter",
"overview": "Overview",
"actions": "Actions",
"cfh": "CFH",
"team": "Team",
"catalogTab": "Catalog",
"rares": "Rares",
"badges": "Badges",
"achievements": "Achievements",
"sounds": "Sounds",
"shopTab": "Shop",
"transactions": "Transactions",
"vouchers": "Vouchers",
"subscriptions": "Subscriptions",
"calendar": "Calendar",
"history": "History",
"moderationTab": "Moderation",
"monitoring": "Monitoring",
"settingsTab": "Settings",
"embed": "Embed",
"apiKeys": "API keys",
"points": "Points",
"ranks": "Ranks",
"autoDj": "Auto DJ",
"cms": "CMS",
"theme": "Theme",
"maintenance": "Maintenance",
"favicon": "Favicon",
"emulator": "Emulator",
"email": "Email",
"housekeeping": "Housekeeping",
"commando": "Commando",
"rooms": "Rooms",
"import": "Import",
"translations": "Translations",
"logs": "Logs",
"audit": "Audit",
"chat": "Chat",
"commands": "Commands",
"trades": "Trades",
"analytics": "Analytics",
"activity": "Activity",
"economy": "Economy",
"devops": "DevOps",
"errors": "Errors",
"alerts": "Alerts",
"toolsGroup": "Tools"
}
},
"wordfilter": {
"title": "Word Filter",
@@ -2149,6 +2267,13 @@
"delete": "Delete",
"noSettings": "No settings found",
"confirmDelete": "Delete setting \"{key}\"?",
"keysInDatabase": "{count} keys in database",
"managedInForm": "{count} managed in form",
"documented": "{count} documented",
"saved": "Settings saved.",
"saveFailed": "Could not save settings.",
"saveSettings": "Save settings",
"viewOnly": "You can view settings but need edit permission to change them.",
"colKey": "Key",
"colValue": "Value",
"colUpdated": "Updated",
+106
View File
@@ -2055,6 +2055,105 @@
"copyUrl": "Copia URL",
"urlCopied": "URL copiato negli appunti"
},
"hubs": {
"newsMedia": "News & media",
"newsMediaSubtitle": "Articoli, foto, banner, ads e media library",
"siteContent": "Contenuti sito",
"siteContentSubtitle": "Navigazione, help, box, tag e prefissi",
"engagement": "Engagement",
"engagementSubtitle": "Eventi, sondaggi e ticket di supporto",
"users": "Utenti",
"usersSubtitle": "Directory, online, candidature e team",
"access": "Accesso e sicurezza",
"accessSubtitle": "Ban, IP, VPN, permessi e word filter",
"moderation": "Moderazione",
"moderationSubtitle": "Report, azioni e team moderazione",
"catalog": "Catalogo e rare",
"catalogSubtitle": "Catalogo, rare, badge, achievement e suoni",
"shop": "Shop e pagamenti",
"shopSubtitle": "Shop, transazioni, voucher, abbonamenti e calendario",
"radio": "Radio",
"radioSubtitle": "Stream, tool DJ, embed e monitoring",
"settings": "Impostazioni",
"settingsSubtitle": "CMS, tema, manutenzione e strumenti correlati",
"tools": "Strumenti",
"toolsSubtitle": "RCON, stanze, import e traduzioni",
"observability": "Osservabilità",
"observabilitySubtitle": "Log, analytics, DevOps e alert hotel",
"tabs": {
"articles": "Articoli",
"photos": "Foto",
"banners": "Banner",
"ads": "Ads",
"media": "Media",
"navigator": "Navigator",
"help": "Help",
"boxes": "Box",
"tags": "Tag",
"prefixes": "Prefissi",
"events": "Eventi",
"eventTypes": "Tipi evento",
"polls": "Sondaggi",
"tickets": "Ticket",
"templates": "Template",
"directory": "Directory",
"multiAccounts": "Multi-account",
"online": "Online",
"applications": "Candidature",
"teams": "Team",
"bans": "Ban",
"ip": "IP",
"vpn": "VPN",
"permissions": "Permessi",
"wordFilter": "Word filter",
"overview": "Panoramica",
"actions": "Azioni",
"cfh": "CFH",
"team": "Team",
"catalogTab": "Catalogo",
"rares": "Rare",
"badges": "Badge",
"achievements": "Achievement",
"sounds": "Suoni",
"shopTab": "Shop",
"transactions": "Transazioni",
"vouchers": "Voucher",
"subscriptions": "Abbonamenti",
"calendar": "Calendario",
"history": "Cronologia",
"moderationTab": "Moderazione",
"monitoring": "Monitoring",
"settingsTab": "Impostazioni",
"embed": "Embed",
"apiKeys": "API key",
"points": "Punti",
"ranks": "Rank",
"autoDj": "Auto DJ",
"cms": "CMS",
"theme": "Tema",
"maintenance": "Manutenzione",
"favicon": "Favicon",
"emulator": "Emulator",
"email": "Email",
"housekeeping": "Housekeeping",
"commando": "Commando",
"rooms": "Stanze",
"import": "Import",
"translations": "Traduzioni",
"logs": "Log",
"audit": "Audit",
"chat": "Chat",
"commands": "Comandi",
"trades": "Trade",
"analytics": "Analytics",
"activity": "Attività",
"economy": "Economia",
"devops": "DevOps",
"errors": "Errori",
"alerts": "Alert",
"toolsGroup": "Strumenti"
}
},
"settings": {
"title": "Impostazioni CMS",
"subtitle": "Gestisci configurazione chiave/valore del CMS",
@@ -2063,6 +2162,13 @@
"delete": "Elimina",
"noSettings": "Nessuna impostazione trovata",
"confirmDelete": "Eliminare l'impostazione \"{key}\"?",
"keysInDatabase": "{count} chiavi nel database",
"managedInForm": "{count} gestite nel form",
"documented": "{count} documentate",
"saved": "Impostazioni salvate.",
"saveFailed": "Impossibile salvare le impostazioni.",
"saveSettings": "Salva impostazioni",
"viewOnly": "Puoi visualizzare le impostazioni ma serve il permesso di modifica.",
"colKey": "Chiave",
"colValue": "Valore",
"colUpdated": "Aggiornato",