Clean up admin hub UX: tabs, chrome, and redundant headers.
Local Build and Deploy / deploy (push) Failing after 53s

Wire orphan routes into hub tabs, group Radio into primary/tools rows, show theme/language once on desktop, and remove duplicate page titles under AdminHubChrome.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-17 18:01:01 +02:00
1 parent 378705dd73
commit d2c0619f2f
75 files changed
+244 -915

No files matched your search

@@ -0,0 +1,78 @@
# Admin UX Cleanup Phase 1 — Implementation Plan
> **For agentic workers:** Execute task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove double hub headers, wire orphan routes into tabs, dedupe theme/language controls, group Radio tabs, and differentiate Access vs Moderation icons.
**Architecture:** Config-driven hubs in `src/lib/admin-nav.ts`; chrome in `AdminHubChrome` / `AdminSectionTabs`; layout controls in `src/app/admin/layout.tsx`. No new routes — only navigation and presentation.
**Tech Stack:** Next.js App Router, React, Tailwind, Lucide, existing admin CSS tokens.
**Spec:** `docs/superpowers/specs/2026-07-17-admin-ux-cleanup-design.md`
---
## File map
| File | Role |
|------|------|
| `src/lib/admin-nav.ts` | Hub tabs, Radio `group`, icon imports |
| `src/components/admin/admin-section-tabs.tsx` | Optional grouped tab rows |
| `src/components/admin/admin-hub-chrome.tsx` | Pass tabs through (no API change if tabs shape extends) |
| `src/app/admin/layout.tsx` | Theme/lang: sidebar always; topbar `lg:hidden` only |
| `src/app/admin/**/page.tsx` (hub lists) | Strip redundant `h1` + duplicate intro blocks |
---
### Task 1: Extend tab type + Radio groups + orphan tabs + icons
**Files:**
- Modify: `src/lib/admin-nav.ts`
- Modify: `src/components/admin/admin-section-tabs.tsx`
- [ ] Add optional `group?: string` to `AdminHubTab` / `AdminTab`
- [ ] Users: add Multi-accounts tab
- [ ] Engagement: add Event types + Templates tabs
- [ ] Observability: add Chat, Commands, Trades, Activity, Economy, Errors; tighten Logs `match` to exact index only via scorer (href-only is enough if subpaths are longer)
- [ ] Radio: set `group: "primary"` / `group: "tools"` on tabs
- [ ] Moderation nav icon → `Gavel` (or `ShieldAlert`); keep Access as `Ban`
- [ ] Render grouped tabs: if any tab has `group`, render one nav per distinct group (label Primary / Tools only for radio tools group — use human labels from group id: `primary` unlabeled or “Main”, `tools` → “Tools”)
### Task 2: Theme / language placement
**Files:**
- Modify: `src/app/admin/layout.tsx`
- [ ] Keep switchers in sidebar
- [ ] Wrap topbar switchers in `className="flex … lg:hidden"` so desktop shows them once
### Task 3: Strip redundant hub list headers
**Files:** Hub **list/overview** pages that duplicate shell title (not entity detail/create):
Strip the page-level title row (`h1` / icon header duplicating hub), keep filters and action buttons.
**Remove duplicate title on (list/tab pages):**
`banners`, `users`, `users/multi-accounts`, `rooms`, `prefixes`, `sounds`, `tickets`, `tickets/templates`, `polls`, `online`, `moderation`, `moderation/actions`, `moderation/cfh`, `moderation/team`, `logs` (if present), `logs/audit`, `logs/chat`, `logs/commands`, `logs/trades`, `events`, `events/types`, `devops`, `devops/errors`, `analytics`, `analytics/activity`, `analytics/economy`, `catalog` (list chrome only)
**Keep title on:**
`polls/create`, `polls/[id]`, `events/create`, `events/[id]`, `catalog/[id]`, and any `show`/`edit`/`new` entity pages
Also strip duplicate headers on other hub tab roots that use different class patterns (articles, photos, ads, radio overview, settings, etc.) if they repeat hub title — grep for similar patterns.
### Task 4: Verify + commit
- [ ] Spot-check tab active states: `/admin/users` vs `/admin/users/multi-accounts`, `/admin/logs` vs `/admin/logs/chat`, `/admin/events` vs `/admin/events/types`
- [ ] Commit + push
---
## Spec coverage
| Spec item | Task |
|-----------|------|
| 1.1 Single hub header | Task 3 |
| 1.2 Orphan routes | Task 1 |
| 1.3 Theme/lang | Task 2 |
| 1.4 Radio groups | Task 1 |
| 1.5 Icons | Task 1 |
-18
View File
@@ -1,4 +1,3 @@
import { Award } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -54,23 +53,6 @@ export default async function AdminAchievements() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Award size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle", {
rows: achievements.length,
categories: groups.size,
})}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("achievementRows")}
+1 -13
View File
@@ -1,4 +1,3 @@
import { Megaphone } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
@@ -31,18 +30,7 @@ export default async function AdminAds() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Megaphone size={20} className="text-[var(--admin-accent)]" />
</div>
<div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
<div className="flex justify-end mb-6">
<Link href="/admin/ads/new" className="btn btn-primary">
+ {t("newAd")}
</Link>
-15
View File
@@ -1,4 +1,3 @@
import { Bell } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { sendHotelAlert } from "@/actions/admin-alerts";
import { StatusCard } from "@/components/admin/dashboard";
@@ -83,20 +82,6 @@ export default async function AdminAlerts() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Bell size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("alertsShown")} value={alerts.length} icon="🔔" />
<StatusCard
@@ -108,13 +108,6 @@ export default async function ActivityPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Activity</h1>
<p className="text-muted-foreground">
User activity breakdown and engagement metrics
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Chat Messages"
-7
View File
@@ -106,13 +106,6 @@ export default async function EconomyPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Economy</h1>
<p className="text-muted-foreground">
Currency circulation, shop revenue and marketplace activity
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Credits in Circulation"
-7
View File
@@ -129,13 +129,6 @@ export default async function AnalyticsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Analytics</h1>
<p className="text-muted-foreground">
Hotel performance overview and trends
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Total Users"
+1 -13
View File
@@ -1,4 +1,3 @@
import { Newspaper } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
@@ -35,18 +34,7 @@ export default async function AdminArticles({
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Newspaper size={20} className="text-[var(--admin-accent)]" />
</div>
<div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
<div className="flex justify-end mb-6">
<Link href="/admin/articles/new" className="btn btn-primary">
+ {t("newArticle")}
</Link>
-15
View File
@@ -1,4 +1,3 @@
import { Award } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { uploadBadge } from "@/actions/admin-badge-upload";
import { giveBadge } from "@/actions/admin-badges";
@@ -35,20 +34,6 @@ export default async function AdminBadges({
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Award size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
{uploaded && (
<div className="mb-4 p-3 rounded-lg bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)] text-xs">
{t("uploadedMessage", { code: uploaded })}
-6
View File
@@ -15,12 +15,6 @@ export default async function BannersPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Banners</h1>
<p className="text-muted-foreground">
Manage homepage banners and promotional slides
</p>
</div>
<BannersManager banners={banners} />
</div>
);
-15
View File
@@ -1,4 +1,3 @@
import { Ban } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { createBan, liftBan } from "@/actions/admin-bans";
@@ -37,20 +36,6 @@ export default async function AdminBans() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Ban size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("activeBans")}
-15
View File
@@ -1,4 +1,3 @@
import { Calendar } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
@@ -65,20 +64,6 @@ export default async function AdminCalendar() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Calendar size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle", { count: campaigns.length })}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("campaigns")} value={campaigns.length} icon="📅" />
<StatusCard
+8 -14
View File
@@ -209,13 +209,10 @@ export default async function CatalogPagesPage({
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Catalog Pages</h1>
<p className="text-muted-foreground">
{totalBcPages} BC pages ({enabledBcPages} enabled) &middot;{" "}
{totalBcItems} items
</p>
</div>
<p className="text-muted-foreground">
{totalBcPages} BC pages ({enabledBcPages} enabled) &middot;{" "}
{totalBcItems} items
</p>
<div className="flex items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
{canEdit && <CatalogActions catalogType="bc" />}
@@ -404,13 +401,10 @@ export default async function CatalogPagesPage({
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Catalog Pages</h1>
<p className="text-muted-foreground">
{totalPages} pages ({enabledPages} enabled) &middot; {totalItems}{" "}
items
</p>
</div>
<p className="text-muted-foreground">
{totalPages} pages ({enabledPages} enabled) &middot; {totalItems}{" "}
items
</p>
<div className="flex items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
{canEdit && <CatalogManagerDialog />}
-15
View File
@@ -1,5 +1,4 @@
import os from "node:os";
import { Activity } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import {
@@ -117,20 +116,6 @@ export default async function CommandoCentrum() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Activity size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
{/* ── Live status ────────────────────────────────────────── */}
<div
className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"
+3 -6
View File
@@ -30,12 +30,9 @@ export default async function EmulatorErrorsPage({
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Emulator Errors</h1>
<p className="text-muted-foreground">
{total} total errors — showing page {page} of {lastPage}
</p>
</div>
<p className="text-muted-foreground">
{total} total errors — showing page {page} of {lastPage}
</p>
<Card>
<CardContent className="p-0">
-7
View File
@@ -59,13 +59,6 @@ export default async function DevOpsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Health Status</h1>
<p className="text-muted-foreground">
System health, emulator status and error monitoring
</p>
</div>
{/* Status cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
-14
View File
@@ -30,20 +30,6 @@ export default async function AdminEmailTemplates() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<span className="text-lg">✉️</span>
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t.rich("subtitle", { code: (chunks) => <code>{chunks}</code> })}
</p>
</div>
</div>
<form action={createEmailTemplate} className="admin-card mb-6">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("newTemplate")}
-7
View File
@@ -49,13 +49,6 @@ export default async function AdminEmulator() {
return (
<main>
<h1>{t("title")}</h1>
<p className="text-xs theme-text-muted dark:theme-text-muted mb-3">
{t.rich("subtitle", {
code: (chunks) => <code>{chunks}</code>,
})}
</p>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("settingsKeys")}
+1 -7
View File
@@ -44,13 +44,7 @@ export default async function EventsPage({
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Events</h1>
<p className="text-muted-foreground">
Manage hotel events, calendar and prizes
</p>
</div>
<div className="flex justify-end">
<Button asChild>
<Link href="/admin/events/create">New Event</Link>
</Button>
-7
View File
@@ -15,13 +15,6 @@ export default async function EventTypesPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Event Types</h1>
<p className="text-muted-foreground">
Manage event categories and types
</p>
</div>
<EventTypesManager eventTypes={eventTypes} />
</div>
);
-18
View File
@@ -1,5 +1,3 @@
import { Image } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { prisma } from "@/lib/prisma";
import { FaviconForm } from "./favicon-form";
import { redirect } from "next/navigation";
@@ -13,8 +11,6 @@ export default async function AdminFaviconPage() {
redirect("/admin");
}
const t = await getTranslations("pages.admin.favicon");
let faviconUrl: string | null = null;
try {
const setting = await prisma.websiteSetting.findUnique({
@@ -27,20 +23,6 @@ export default async function AdminFaviconPage() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Image size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<FaviconForm currentUrl={faviconUrl} />
</main>
);
+1 -13
View File
@@ -1,4 +1,3 @@
import { HelpCircle } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
@@ -32,18 +31,7 @@ export default async function AdminHelpQuestions() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<HelpCircle size={20} className="text-[var(--admin-accent)]" />
</div>
<div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
<div className="flex justify-end mb-6">
<Link href="/admin/help-questions/new" className="btn btn-primary">
+ {t("newEntry")}
</Link>
-14
View File
@@ -63,20 +63,6 @@ export default async function AdminHousekeeping({
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<span className="text-lg">🔑</span>
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="admin-stat-row">
<span className="admin-stat-chip">
{t("permissionsCount", { count: permissions.length })}
-5
View File
@@ -66,11 +66,6 @@ export default async function ImportPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">{t("title")}</h1>
<p className="text-muted-foreground">{t("subtitle")}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{SECTIONS.map(({ href, label, icon: Icon, description }) => (
<Link
-15
View File
@@ -1,4 +1,3 @@
import { Shield } from "lucide-react";
import { getTranslations } from "next-intl/server";
import {
addBlacklist,
@@ -54,20 +53,6 @@ export default async function AdminIp() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Shield size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("whitelisted")}
+4 -2
View File
@@ -147,8 +147,10 @@ async function Topbar({
</span>
</div>
<div className="flex items-center gap-3">
<LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" />
<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>
-5
View File
@@ -37,11 +37,6 @@ export default async function AuditLogsPage({
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Audit Logs</h1>
<p className="text-muted-foreground">Track admin actions and changes</p>
</div>
<AuditTable data={{ rows, ...pagination }} />
</div>
);
-5
View File
@@ -30,11 +30,6 @@ export default async function ChatLogsPage({
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Chat Logs</h1>
<p className="text-muted-foreground">View in-game chat messages</p>
</div>
<ChatTable
data={{
rows: result.rows,
-5
View File
@@ -30,11 +30,6 @@ export default async function CommandLogsPage({
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Command Logs</h1>
<p className="text-muted-foreground">View in-game command usage</p>
</div>
<CommandsTable
data={{
rows: result.rows,
-15
View File
@@ -1,4 +1,3 @@
import { ScrollText } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
@@ -90,20 +89,6 @@ export default async function AdminStaffActivities({
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<ScrollText size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("totalActivities")} value={total} icon="📋" />
<StatusCard
-5
View File
@@ -33,11 +33,6 @@ export default async function TradeLogsPage({
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Trade Logs</h1>
<p className="text-muted-foreground">View in-game trade history</p>
</div>
<TradesTable
data={{
rows: result.rows,
-15
View File
@@ -1,4 +1,3 @@
import { Wrench } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { saveMaintenance } from "@/actions/admin-maintenance";
import { StatusCard } from "@/components/admin/dashboard";
@@ -43,20 +42,6 @@ export default async function AdminMaintenance() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Wrench size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t.rich("subtitle", { code: (chunks) => <code>{chunks}</code> })}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("maintenance")}
+1 -13
View File
@@ -1,4 +1,3 @@
import { Image } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { AdminMediaGrid } from "@/components/admin/media-grid";
@@ -15,18 +14,7 @@ export default async function AdminMediaPage() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Image size={20} className="text-[var(--admin-accent)]" />
</div>
<div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
<div className="flex justify-end mb-6">
<Link
href="/admin/articles"
className="text-sm theme-text-muted dark:theme-text-muted"
@@ -9,12 +9,6 @@ export default async function QuickActionsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Quick Actions</h1>
<p className="text-muted-foreground">
Fast moderation tools — kick, mute, alert, broadcast
</p>
</div>
<ModActionsClient />
</div>
);
-5
View File
@@ -74,11 +74,6 @@ export default async function CfhListPage({ searchParams }: PageProps) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">CFH Tickets</h1>
<p className="text-muted-foreground">In-game Call For Help reports</p>
</div>
{/* Filters */}
<div className="flex gap-2">
{[
-5
View File
@@ -81,11 +81,6 @@ export default async function ModerationDashboard() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Moderation</h1>
<p className="text-muted-foreground">In-game moderation dashboard</p>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
-5
View File
@@ -58,11 +58,6 @@ export default async function ModTeamPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Mod Team</h1>
<p className="text-muted-foreground">Staff performance and status</p>
</div>
{/* Summary */}
<div className="grid gap-4 grid-cols-3">
<Card>
-15
View File
@@ -1,4 +1,3 @@
import { Navigation } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -116,20 +115,6 @@ export default async function AdminNavigation() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Navigation size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("flatCategories")}
+3 -6
View File
@@ -29,12 +29,9 @@ export default async function OnlineUsersPage({
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Online Users</h1>
<p className="text-muted-foreground">
Currently online users ({result.total} connected)
</p>
</div>
<p className="text-muted-foreground">
Currently online users ({result.total} connected)
</p>
<OnlineTable
data={{
+4 -10
View File
@@ -100,16 +100,10 @@ export function PermissionsList({ ranks: initialRanks }: Props) {
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<ShieldCheck className="h-7 w-7" />
Permissions
</h1>
<p className="text-muted-foreground">
{ranks.length} ranks &middot; {totalUsers.toLocaleString()} users
total
</p>
</div>
<p className="text-muted-foreground">
{ranks.length} ranks &middot; {totalUsers.toLocaleString()} users
total
</p>
<Button onClick={() => setShowCreate(true)}>
<Plus className="h-4 w-4 mr-1.5" />
New Rank
-15
View File
@@ -1,4 +1,3 @@
import { Image } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { deletePhoto } from "@/actions/admin-photos";
import { StatusCard } from "@/components/admin/dashboard";
@@ -30,20 +29,6 @@ export default async function AdminPhotos() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Image size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("photosShown")} value={photos.length} icon="📸" />
<StatusCard
+1 -7
View File
@@ -43,13 +43,7 @@ export default async function PollsPage({
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Polls</h1>
<p className="text-muted-foreground">
Manage community polls and surveys
</p>
</div>
<div className="flex justify-end">
<Button asChild>
<Link href="/admin/polls/create">New Poll</Link>
</Button>
-6
View File
@@ -17,12 +17,6 @@ export default async function PrefixesPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Custom Prefixes</h1>
<p className="text-muted-foreground">
Manage user prefixes, blacklist and settings
</p>
</div>
<PrefixesClient canEdit={canEdit} />
</div>
);
-25
View File
@@ -1,5 +1,3 @@
import { Key } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import {
createApiKey,
@@ -71,30 +69,7 @@ export default async function AdminRadioApiKeysPage() {
return (
<main>
<nav className="text-xs theme-text-muted dark:theme-text-muted mb-2">
<Link href="/admin/radio">{t("title")}</Link> ·{" "}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{" "}
<Link href="/admin/radio/api-keys">{t("apiKeys")}</Link> ·{" "}
<Link href="/admin/radio/autodj">{t("autoDj")}</Link> ·{" "}
<Link href="/admin/radio/embed">{t("embed")}</Link> ·{" "}
<Link href="/admin/radio/points">{t("points")}</Link> ·{" "}
<Link href="/admin/radio/monitoring">{t("monitoring.title")}</Link>
</nav>
<section className="mt-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Key size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("apiKeysForm.title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("apiKeysForm.subtitle")}
</p>
</div>
</div>
{dbError ? (
<div className="admin-card" style={{ marginTop: "1rem" }}>
-24
View File
@@ -1,6 +1,3 @@
import { Disc3 } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import {
createTrack,
@@ -75,29 +72,8 @@ export default async function AdminRadioAutoDjPage() {
return (
<main>
<nav className="text-xs theme-text-muted dark:theme-text-muted mb-2">
<Link href="/admin/radio">{t("title")}</Link> ·{" "}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{" "}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{" "}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{" "}
<Link href="/admin/radio/history">{t("history")}</Link> ·{" "}
<Link href="/admin/radio/autodj">{t("autoDj")}</Link>
</nav>
<section className="mt-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Disc3 size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("autoDjForm.title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("autoDjForm.subtitle")}
</p>
</div>
</div>
{dbError ? (
<div className="admin-card mb-6">
-24
View File
@@ -1,6 +1,4 @@
import { Image as LucideImage } from "lucide-react";
import NextImage from "next/image";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import {
createRadioBanner,
@@ -57,28 +55,6 @@ export default async function AdminRadioBannersPage() {
return (
<main>
<nav className="text-xs theme-text-muted dark:theme-text-muted mb-2">
<Link href="/admin/radio">{t("title")}</Link> ·{" "}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{" "}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{" "}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{" "}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<LucideImage size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("banners.heading")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("banners.description")}
</p>
</div>
</div>
{dbError ? (
<div className="admin-card mb-6">
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
-24
View File
@@ -1,4 +1,3 @@
import { Code } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
@@ -41,30 +40,7 @@ export default async function AdminRadioEmbedPage() {
return (
<main>
<nav className="text-xs theme-text-muted dark:theme-text-muted mb-2">
<Link href="/admin/radio">{t("title")}</Link> ·{" "}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{" "}
<Link href="/admin/radio/embed">{t("embed")}</Link> ·{" "}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{" "}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{" "}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<section className="mt-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Code size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("embedPage.title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("embedPage.subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("embedPage.streamUrl")}
-24
View File
@@ -1,5 +1,3 @@
import { Clock } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -76,28 +74,6 @@ export default async function AdminRadioHistoryPage() {
return (
<main>
<nav className="text-xs theme-text-muted dark:theme-text-muted mb-2">
<Link href="/admin/radio">{t("title")}</Link> ·{" "}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{" "}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{" "}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{" "}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Clock size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("historyPage.title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("historyPage.subtitle")}
</p>
</div>
</div>
{dbError ? (
<div className="admin-card mb-6">
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
-24
View File
@@ -1,5 +1,3 @@
import { Shield } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { deleteShout } from "@/actions/admin-radio-moderation";
import { StatusCard } from "@/components/admin/dashboard";
@@ -41,28 +39,6 @@ export default async function AdminRadioModerationPage() {
return (
<main>
<nav className="text-xs theme-text-muted dark:theme-text-muted mb-2">
<Link href="/admin/radio">{t("title")}</Link> ·{" "}
<Link href="/admin/radio/moderation">{t("moderation")}</Link> ·{" "}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{" "}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{" "}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{" "}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Shield size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("moderationPage.title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("moderationPage.subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
-24
View File
@@ -1,5 +1,3 @@
import { Radio } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { DiagnosticRow, StatusCard } from "@/components/admin/dashboard";
import { siteSettings } from "@/lib/services/site-settings";
@@ -213,30 +211,8 @@ export default async function AdminRadioMonitoringPage() {
return (
<main>
<nav className="text-xs theme-text-muted dark:theme-text-muted mb-2">
<Link href="/admin/radio">{t("title")}</Link> ·{" "}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{" "}
<Link href="/admin/radio/monitoring">{t("monitoring.title")}</Link> ·{" "}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{" "}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{" "}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<section className="mt-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Radio size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("monitoring.title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("monitoring.subtitle", { timeout: FETCH_TIMEOUT_MS / 1000 })}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("monitoring.stream")}
-14
View File
@@ -1,4 +1,3 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { deleteShout } from "@/actions/admin-radio-moderation";
import { StatusCard } from "@/components/admin/dashboard";
@@ -110,19 +109,6 @@ export default async function AdminRadioPage() {
return (
<main>
<nav className="text-xs theme-text-muted dark:theme-text-muted mb-2">
<Link href="/admin/radio">{t("title")}</Link> ·{" "}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{" "}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{" "}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{" "}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<h1>{t("title")}</h1>
<p className="text-xs theme-text-muted dark:theme-text-muted">
{t("subtitle")}
</p>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("shouts")}
-24
View File
@@ -1,5 +1,3 @@
import { Star } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { savePoints } from "@/actions/admin-radio-points";
import { StatusCard } from "@/components/admin/dashboard";
@@ -63,30 +61,8 @@ export default async function AdminRadioPointsPage({
return (
<main>
<nav className="text-xs theme-text-muted dark:theme-text-muted mb-2">
<Link href="/admin/radio">{t("title")}</Link> ·{" "}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{" "}
<Link href="/admin/radio/points">{t("points")}</Link> ·{" "}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{" "}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{" "}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<section className="mt-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Star size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("pointsForm.title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("pointsForm.subtitle")}
</p>
</div>
</div>
{saved === "1" ? (
<p>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-success-subtle)] text-[var(--admin-text)]">
-24
View File
@@ -1,5 +1,3 @@
import { Medal } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import {
createRadioRank,
@@ -45,28 +43,6 @@ export default async function AdminRadioRanksPage() {
return (
<main>
<nav className="text-xs theme-text-muted dark:theme-text-muted mb-2">
<Link href="/admin/radio">{t("title")}</Link> ·{" "}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{" "}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{" "}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{" "}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Medal size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("ranksForm.title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("ranksForm.subtitle")}
</p>
</div>
</div>
{dbError ? (
<div className="admin-card mb-6">
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
-24
View File
@@ -1,5 +1,3 @@
import { Settings } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import {
saveRadioSetting,
@@ -397,28 +395,6 @@ export default async function AdminRadioSettingsPage() {
return (
<main>
<nav className="text-xs theme-text-muted dark:theme-text-muted mb-2">
<Link href="/admin/radio">{t("title")}</Link> ·{" "}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{" "}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{" "}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{" "}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Settings size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("settingsForm.title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("settingsForm.subtitle")}
</p>
</div>
</div>
{dbError ? (
<div className="admin-card mb-6">
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
-15
View File
@@ -1,4 +1,3 @@
import { Gem } from "lucide-react";
import { getTranslations } from "next-intl/server";
import {
createCategory,
@@ -54,20 +53,6 @@ export default async function AdminRareValues() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Gem size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("categories")}
+3 -6
View File
@@ -48,12 +48,9 @@ export default async function RoomsPage({
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Rooms</h1>
<p className="text-muted-foreground">
{result.total.toLocaleString()} rooms total
</p>
</div>
<p className="text-muted-foreground">
{result.total.toLocaleString()} rooms total
</p>
<RoomsTable
data={{
+1 -5
View File
@@ -64,15 +64,11 @@ export default async function AdminShop() {
return (
<main>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<h1 style={{ flex: 1 }}>{t("title")}</h1>
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: "1rem" }}>
<Link href="/admin/shop/new" className="btn btn-primary">
{t("newPackage")}
</Link>
</div>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
{t("subtitle")} (<code>website_shop_articles</code>). {t("tableHint")}
</p>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("packages")} value={packages.length} icon="🛍️" />
+7 -12
View File
@@ -67,18 +67,13 @@ export default async function SoundsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Song Disks</h1>
<p className="text-muted-foreground">
Upload and manage MP3 files for song disks played by in-game
jukeboxes. Files are served from <code>{baseUrl}</code>{" "}
(configurable in{" "}
<a href="/admin/settings" className="underline">
Settings
</a>
).
</p>
</div>
<p className="text-muted-foreground">
Files are served from <code>{baseUrl}</code> (configurable in{" "}
<a href="/admin/settings" className="underline">
Settings
</a>
).
</p>
<SoundsClient
soundtracks={soundtracks}
canEdit={canEdit}
-15
View File
@@ -1,4 +1,3 @@
import { CreditCard } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -54,20 +53,6 @@ export default async function AdminSubscriptions() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<CreditCard size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("activeSubs")} value={active.length} icon="⭐" />
<StatusCard label={t("types")} value={types} icon="🏷️" />
-15
View File
@@ -1,4 +1,3 @@
import { Hash } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { createTag, deleteTag, updateTag } from "@/actions/admin-tags";
import { StatusCard } from "@/components/admin/dashboard";
@@ -53,20 +52,6 @@ export default async function AdminTagsPage() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Hash size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
{dbError ? (
<div className="admin-card" style={{ marginBottom: "1.5rem" }}>
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
-14
View File
@@ -372,20 +372,6 @@ export default async function AdminTheme({
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<span className="text-lg">🎨</span>
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t.rich("subtitle", { code: (chunks) => <code>{chunks}</code> })}
</p>
</div>
</div>
{sp.saved ? (
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">
{t("saved")}
-5
View File
@@ -90,11 +90,6 @@ export default async function AdminTicketsPage({ searchParams }: PageProps) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Support Tickets</h1>
<p className="text-muted-foreground">Manage user support requests</p>
</div>
{/* Stats */}
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
<Card>
-6
View File
@@ -15,12 +15,6 @@ export default async function TicketTemplatesPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Reply Templates</h1>
<p className="text-muted-foreground">
Pre-defined responses for common ticket situations
</p>
</div>
<TicketTemplatesManager templates={templates} />
</div>
);
-15
View File
@@ -1,4 +1,3 @@
import { ArrowLeftRight } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
@@ -97,20 +96,6 @@ export default async function AdminTransactions({
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<ArrowLeftRight size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("totalTransactions")} value={total} icon="🧾" />
<StatusCard
@@ -1,6 +1,6 @@
"use client";
import { Download, Gamepad2, Info, Lock, Save } from "lucide-react";
import { Download, Info, Lock, Save } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -100,11 +100,7 @@ export function ClientTranslations({
return (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Gamepad2 className="w-6 h-6" />
Client (Nitro)
</h1>
<div className="flex justify-end flex-wrap gap-2">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="w-4 h-4 mr-1" /> Export
+2 -9
View File
@@ -1,4 +1,4 @@
import { ExternalLink, Server } from "lucide-react";
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { EmulatorSettingsForm } from "@/components/admin/emulator-settings-form";
@@ -74,14 +74,7 @@ export default async function EmulatorTranslationsPage({
return (
<div className="space-y-4">
<div className="flex items-start justify-between flex-wrap gap-2">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Server className="w-6 h-6" />
{t("title")}
</h1>
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
<div className="flex justify-end">
<Button asChild variant="outline" size="sm">
<Link href="/admin/emulator">
<ExternalLink className="w-4 h-4 mr-1" />
@@ -1,6 +1,6 @@
"use client";
import { Download, Globe, Save } from "lucide-react";
import { Download, Save } from "lucide-react";
import { useMemo, useState } from "react";
import { saveTranslations } from "@/actions/translations";
import { Button } from "@/components/ui/button";
@@ -138,11 +138,7 @@ export function TranslationsClient({ localeContents }: Props) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Globe className="w-6 h-6" />
CMS
</h1>
<div className="flex justify-end">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="w-4 h-4 mr-1" /> Export
@@ -28,16 +28,6 @@ export default async function MultiAccountsPage({
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Multi-account detector
</h1>
<p className="text-muted-foreground">
Clusters of users sharing a registration IP, current IP, or machine
ID. Detection is passive — no AI, just a GROUP BY with index support.
</p>
</div>
<form className="flex flex-wrap items-end gap-3" action="">
<label className="text-sm">
<span className="block text-muted-foreground">
+3 -6
View File
@@ -49,12 +49,9 @@ export default async function UsersPage({
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Users</h1>
<p className="text-muted-foreground">
{result.total.toLocaleString()} users total
</p>
</div>
<p className="text-muted-foreground">
{result.total.toLocaleString()} users total
</p>
{canEdit && <CreateUserDialog sessionRank={result.rank} />}
</div>
-15
View File
@@ -1,4 +1,3 @@
import { Gift } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { createVoucher, deleteVoucher } from "@/actions/admin-vouchers";
import { StatusCard } from "@/components/admin/dashboard";
@@ -65,20 +64,6 @@ export default async function AdminVouchers() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Gift size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("vouchers")} value={vouchers.length} icon="🎟️" />
<StatusCard
-17
View File
@@ -1,4 +1,3 @@
import { ShieldBan } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { saveVpn } from "@/actions/admin-vpn";
@@ -41,22 +40,6 @@ export default async function AdminVpn({
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<ShieldBan size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t.rich("subtitle", {
ipLink: (chunks) => <Link href="/admin/ip">{chunks}</Link>,
})}
</p>
</div>
</div>
<section className="mt-6">
{sp.saved ? (
<p className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-success-subtle)] text-[var(--admin-text)]">
-15
View File
@@ -1,4 +1,3 @@
import { Filter } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { addWord, deleteWord } from "@/actions/admin-wordfilter";
@@ -31,20 +30,6 @@ export default async function AdminWordFilter() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Filter size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("filteredWords")} value={words.length} icon="🚫" />
</div>
-15
View File
@@ -1,4 +1,3 @@
import { Layout } from "lucide-react";
import { getTranslations } from "next-intl/server";
import {
createBox,
@@ -33,20 +32,6 @@ export default async function AdminWriteableBoxes() {
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Layout size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<section className="mt-6">
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("totalBoxes")} value={boxes.length} icon="🗂️" />
+79 -20
View File
@@ -9,14 +9,66 @@ export interface AdminTab {
label: string;
/** Extra prefixes that count as active for this tab (detail routes). */
match?: string[];
/** Optional group id for multi-row strips (e.g. Radio primary/tools). */
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;
return -1;
}
function TabRow({
tabs,
bestIndex,
globalOffset,
label,
}: {
tabs: AdminTab[];
bestIndex: number;
globalOffset: number;
label?: string;
}) {
return (
<div className="min-w-0">
{label ? (
<div className="px-1 pb-1 text-[0.65rem] font-bold uppercase tracking-widest text-[var(--admin-text-muted)]">
{label}
</div>
) : null}
<nav
className="flex gap-1 overflow-x-auto border-b border-[var(--admin-border)] -mx-1 px-1"
aria-label={label ? `Section ${label}` : "Section"}
>
{tabs.map((tab, index) => {
const active = globalOffset + index === bestIndex;
return (
<Link
key={tab.href}
href={tab.href}
aria-current={active ? "page" : undefined}
className={cn(
"-mb-px shrink-0 rounded-t-lg border-b-2 px-3.5 py-2.5 text-sm font-semibold no-underline transition-colors",
active
? "border-[var(--admin-accent)] text-[var(--admin-text)] bg-[var(--admin-accent)]/8"
: "border-transparent text-[var(--admin-text-muted)] hover:text-[var(--admin-text)] hover:bg-[var(--admin-accent)]/5",
)}
>
{tab.label}
</Link>
);
})}
</nav>
</div>
);
}
export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
const pathname = usePathname() ?? "";
@@ -33,29 +85,36 @@ export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
}
});
const hasGroups = tabs.some((t) => t.group);
if (!hasGroups) {
return (
<TabRow tabs={tabs} bestIndex={bestIndex} globalOffset={0} />
);
}
const order: string[] = [];
for (const tab of tabs) {
const g = tab.group ?? "primary";
if (!order.includes(g)) order.push(g);
}
let offset = 0;
return (
<nav
className="flex gap-1 overflow-x-auto border-b border-[var(--admin-border)] -mx-1 px-1"
aria-label="Section"
>
{tabs.map((tab, index) => {
const active = index === bestIndex;
<div className="space-y-3">
{order.map((groupId) => {
const groupTabs = tabs.filter((t) => (t.group ?? "primary") === groupId);
const rowOffset = offset;
offset += groupTabs.length;
return (
<Link
key={tab.href}
href={tab.href}
aria-current={active ? "page" : undefined}
className={cn(
"-mb-px shrink-0 rounded-t-lg border-b-2 px-3.5 py-2.5 text-sm font-semibold no-underline transition-colors",
active
? "border-[var(--admin-accent)] text-[var(--admin-text)] bg-[var(--admin-accent)]/8"
: "border-transparent text-[var(--admin-text-muted)] hover:text-[var(--admin-text)] hover:bg-[var(--admin-accent)]/5",
)}
>
{tab.label}
</Link>
<TabRow
key={groupId}
tabs={groupTabs}
bestIndex={bestIndex}
globalOffset={rowOffset}
label={GROUP_LABELS[groupId] || undefined}
/>
);
})}
</nav>
</div>
);
}
+39 -15
View File
@@ -4,6 +4,7 @@ import {
Calendar,
Cog,
Compass,
Gavel,
LayoutDashboard,
Monitor,
Newspaper,
@@ -20,6 +21,8 @@ export interface AdminHubTab {
href: string;
label: string;
match?: string[];
/** Optional group id for multi-row tab strips (e.g. Radio primary/tools). */
group?: string;
}
export interface AdminHubDefinition {
@@ -103,12 +106,14 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
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/tickets",
label: "Tickets",
match: ["/admin/tickets"],
},
{ href: "/admin/tickets/templates", label: "Templates" },
],
},
{
@@ -123,7 +128,12 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
"/admin/teams",
],
tabs: [
{ href: "/admin/users", label: "Directory", match: ["/admin/users"] },
{
href: "/admin/users",
label: "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" },
@@ -157,7 +167,7 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
id: "moderation",
title: "Moderation",
subtitle: "Reports, actions and moderation team",
icon: Shield,
icon: Gavel,
prefixes: ["/admin/moderation"],
tabs: [
{ href: "/admin/moderation", label: "Overview" },
@@ -221,17 +231,25 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
icon: Radio,
prefixes: ["/admin/radio"],
tabs: [
{ href: "/admin/radio", label: "Overview" },
{ href: "/admin/radio/history", label: "History" },
{ href: "/admin/radio/banners", label: "Banners" },
{ href: "/admin/radio/embed", label: "Embed" },
{ href: "/admin/radio/api-keys", label: "API keys" },
{ href: "/admin/radio/moderation", label: "Moderation" },
{ href: "/admin/radio/settings", label: "Settings" },
{ href: "/admin/radio/monitoring", label: "Monitoring" },
{ href: "/admin/radio/points", label: "Points" },
{ href: "/admin/radio/ranks", label: "Ranks" },
{ href: "/admin/radio/autodj", label: "Auto DJ" },
{ href: "/admin/radio", label: "Overview", group: "primary" },
{ href: "/admin/radio/history", label: "History", group: "primary" },
{
href: "/admin/radio/moderation",
label: "Moderation",
group: "primary",
},
{
href: "/admin/radio/monitoring",
label: "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" },
],
},
{
@@ -292,14 +310,20 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
"/admin/alerts",
],
tabs: [
{ href: "/admin/logs", label: "Logs", match: ["/admin/logs"] },
{ 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/analytics",
label: "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" },
],
},
@@ -373,7 +397,7 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
{
href: "/admin/moderation",
labelKey: "moderation",
icon: Shield,
icon: Gavel,
matchPrefixes: hubPrefixes("moderation"),
},
],