feat(admin): color-code dashboard Overview by category, unify depth system

The Overview card was a flat, uncategorized list of 10 stats with no visual
grouping. Split it into three labeled, color-identified groups so a moderator
can scan by kind of number instead of reading every label:

- Activity (emerald): owners, pets, posts, DAU, MAU
- Violations (rose): banned users, shadowbanned, blacklist entries — values
  only pick up the color when actually > 0, same "act on this" convention as
  the existing AttentionStat tiles
- Info (sky): active ads, verifications closed

Also switched the maintenance banner and AttentionStat tiles from border to
ring-1, matching the shared Card component's depth convention so the whole
dashboard uses one visual vocabulary instead of two.
This commit is contained in:
admin committed 2026-08-25 19:09:34 +02:00
1 parent f12f4edc69
commit 2f7cacfc82
1 file changed
+102 -30
+102 -30
View File
@@ -3,7 +3,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { Flag, Clock, TriangleAlert } from "lucide-react";
import { Flag, Clock, TriangleAlert, TrendingUp, ShieldAlert, Info } from "lucide-react";
import { TimeSeriesChart } from "@/components/admin/TimeSeriesChart";
import { GranularityToggle, type Granularity } from "@/components/admin/GranularityToggle";
import { Button } from "@/components/ui/button";
@@ -56,10 +56,10 @@ export default function AdminDashboard() {
{canToggleMaintenance && (
<div
className={`flex items-center justify-between gap-4 rounded-xl border px-5 py-4 ${
className={`flex items-center justify-between gap-4 rounded-xl ring-1 px-5 py-4 ${
maintenance?.enabled
? "border-destructive/40 bg-destructive/10"
: "border-border bg-card"
? "ring-destructive/40 bg-destructive/10"
: "ring-foreground/10 bg-card"
}`}
>
<div className="flex items-center gap-3">
@@ -92,27 +92,60 @@ export default function AdminDashboard() {
<>
{/* Needs attention — the only stats that earn individual weight and
real color; color means "act on this", not "posts are orange". */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<AttentionStat icon={Flag} label="Open reports" value={stats?.reports ?? 0} />
<AttentionStat icon={Clock} label="Verifications pending" value={stats?.verificationPending ?? 0} />
<div>
<h2 className="text-sm font-semibold text-foreground mb-3">Needs attention</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<AttentionStat icon={Flag} label="Open reports" value={stats?.reports ?? 0} />
<AttentionStat icon={Clock} label="Verifications pending" value={stats?.verificationPending ?? 0} />
</div>
</div>
{/* Everything else — a flat overview, not a wall of identical cards */}
<div className="rounded-xl border border-border bg-card p-5">
<h2 className="text-sm font-semibold text-foreground mb-4">Overview</h2>
<dl className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-4">
<OverviewStat label="Owners" value={stats?.owners ?? 0} />
<OverviewStat label="Pets" value={stats?.pets ?? 0} />
<OverviewStat label="Posts" value={stats?.posts ?? 0} />
<OverviewStat label="DAU (today)" value={stats?.dau ?? 0} />
<OverviewStat label="MAU (30d)" value={stats?.mau ?? 0} />
<OverviewStat label="Active ads" value={stats?.activeAds ?? 0} />
<OverviewStat label="Banned users" value={stats?.bans ?? 0} />
<OverviewStat label="Shadowbanned" value={stats?.shadowbans ?? 0} />
<OverviewStat label="Blacklist entries" value={stats?.blacklistEntries ?? 0} />
<OverviewStat label="Verifications closed" value={stats?.verificationClosed ?? 0} />
</dl>
</div>
{/* Everything else, grouped by what kind of number it is — activity
(growth/usage), violations (moderation risk), info (context only).
Category color is a legend, not a wash: only violation values
that are actually > 0 pick up the color, same "act on this"
logic as AttentionStat above. */}
<Card>
<CardHeader>
<CardTitle>Overview</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<StatGroup
icon={TrendingUp}
label="Activity"
color="emerald"
items={[
{ label: "Owners", value: stats?.owners ?? 0 },
{ label: "Pets", value: stats?.pets ?? 0 },
{ label: "Posts", value: stats?.posts ?? 0 },
{ label: "DAU (today)", value: stats?.dau ?? 0 },
{ label: "MAU (30d)", value: stats?.mau ?? 0 },
]}
/>
<StatGroup
icon={ShieldAlert}
label="Violations"
color="rose"
emphasizeNonZero
items={[
{ label: "Banned users", value: stats?.bans ?? 0 },
{ label: "Shadowbanned", value: stats?.shadowbans ?? 0 },
{ label: "Blacklist entries", value: stats?.blacklistEntries ?? 0 },
]}
/>
<StatGroup
icon={Info}
label="Info"
color="sky"
items={[
{ label: "Active ads", value: stats?.activeAds ?? 0 },
{ label: "Verifications closed", value: stats?.verificationClosed ?? 0 },
]}
/>
</div>
</CardContent>
</Card>
</>
)}
@@ -227,8 +260,8 @@ function AttentionStat({
const needsAttention = value > 0;
return (
<div
className={`rounded-xl border px-5 py-4 flex items-center gap-4 ${
needsAttention ? "border-orange-500/30 bg-orange-500/5" : "border-border bg-card"
className={`rounded-xl ring-1 px-5 py-4 flex items-center gap-4 ${
needsAttention ? "ring-orange-500/30 bg-orange-500/5" : "ring-foreground/10 bg-card"
}`}
>
<Icon className={`h-5 w-5 shrink-0 ${needsAttention ? "text-orange-400" : "text-muted-foreground"}`} />
@@ -242,12 +275,51 @@ function AttentionStat({
);
}
/** Everything else — a plain label/value pair, no per-item card chrome. */
function OverviewStat({ label, value }: { label: string; value: number }) {
const STAT_GROUP_COLORS = {
emerald: { text: "text-emerald-400", bg: "bg-emerald-500/5", ring: "ring-emerald-500/15" },
rose: { text: "text-rose-400", bg: "bg-rose-500/5", ring: "ring-rose-500/15" },
sky: { text: "text-sky-400", bg: "bg-sky-500/5", ring: "ring-sky-500/15" },
} as const;
/**
* One labeled, color-identified slice of the Overview card — Activity,
* Violations, or Info. The color lives on the group's icon/label (a legend)
* and, when `emphasizeNonZero` is set, on individual values that are actually
* non-zero — never as a flat wash across numbers that don't mean anything yet.
*/
function StatGroup({
icon: Icon,
label,
color,
items,
emphasizeNonZero = false,
}: {
icon: React.ElementType;
label: string;
color: keyof typeof STAT_GROUP_COLORS;
items: { label: string; value: number }[];
emphasizeNonZero?: boolean;
}) {
const c = STAT_GROUP_COLORS[color];
return (
<div>
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="text-lg font-semibold text-foreground mt-0.5">{value.toLocaleString()}</dd>
<div className={`rounded-lg ${c.bg} ring-1 ${c.ring} p-4`}>
<div className={`flex items-center gap-1.5 text-xs font-semibold ${c.text} mb-3`}>
<Icon className="h-3.5 w-3.5" />
{label}
</div>
<dl className="space-y-2.5">
{items.map((item) => {
const flagged = emphasizeNonZero && item.value > 0;
return (
<div key={item.label} className="flex items-baseline justify-between gap-3">
<dt className="text-xs text-muted-foreground">{item.label}</dt>
<dd className={`text-sm font-semibold tabular-nums ${flagged ? c.text : "text-foreground"}`}>
{item.value.toLocaleString()}
</dd>
</div>
);
})}
</dl>
</div>
);
}