Layer 0 — Foundation: - Dark mode palette (30+ CSS var pairs in [data-theme='dark']) - CSS-first theme engine in styles.css (@theme + CSS vars) - UseTheme hook with light/dark/system support + localStorage persistence - Fixed animation keyframes (shimmer 1.5s canonical, glowPulse moved to CSS) - Smooth theme switch transitions via .theme-transitioning class Layer 1 — Shared UI Components: - Badge: success/warning variants now use CSS vars (bg-success-soft text-success) - Toast: rewritten with CSS vars, z-index fixed (40), repositioned top-right - Skeleton: added variant prop (rounded/circular/rectangular) - EmptyState: new component with icon + title + description + action - Button: added tertiary variant + icon-sm size - Card: added elevated/bordered variants - Input: added soft variant Layer 2 — Layout & Navigation: - TabStrip: new horizontal tab navigation with spring underline indicator (z-30) - Sidebar: expanded by default (w-64), brand assets always visible - Header: simplified brand bar, removed redundant page titles, added ThemeToggle - MobileTabBar: enhanced with spring dot indicator + glass bg + safe area - DashboardLayout: integrated TabStrip between Header and content - ParticleBackground: lazy render, skips on mobile/reduced-motion Layer 3 — Feature Components: - MessageCard: 30+ hardcoded hex replacements → semantic CSS vars - MessagesPanel: stat badges use Badge component variants - DashboardStats: StatCard with variant system (primary/success/warning/destructive) - UserSummaryList/UserProfileDetail/ChannelProfileDetail: all hardcoded colors → CSS vars - AudioVisualizer: reads --primary CSS var at paint time - ActiveSpeakers/RecordingsSubPanel: hardcoded colors → CSS vars Layer 4 — Polish: - EmptyState integrated across messages/dashboard/live panels - Theme toggle wired in Header + App root - Hover state audit for consistency - Entry animations verified (cardStagger/cardItem pattern in all panels) Resolves DESIGN_TOKENS.md §13.x issues: hardcoded colors, shimmer mismatch, glow-pulse fragmentation, z-index collisions, toast positioning.
247 lines
7.4 KiB
TypeScript
247 lines
7.4 KiB
TypeScript
import { motion } from "framer-motion";
|
|
import {
|
|
AlertCircle,
|
|
BarChart3,
|
|
MessageSquare,
|
|
Mic,
|
|
RefreshCw,
|
|
ShieldAlert,
|
|
UserCheck,
|
|
Users,
|
|
} from "lucide-react";
|
|
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
|
|
import { useUIState } from "../../../shared/hooks/useUIState.js";
|
|
import { cn } from "../../../shared/lib/utils";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
Skeleton,
|
|
StatusBadge,
|
|
} from "../../../shared/ui";
|
|
import { useDashboardStats } from "../hooks/useDashboard";
|
|
|
|
export function DashboardStatsContent() {
|
|
const { stats, loading, error, refetch } = useDashboardStats();
|
|
const { patchUIState } = useUIState();
|
|
|
|
if (loading) {
|
|
return <StatsSkeleton />;
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex flex-col items-center gap-4 py-20 text-muted-foreground">
|
|
<AlertCircle className="h-10 w-10 text-destructive" />
|
|
<p className="text-sm">{error}</p>
|
|
<button
|
|
onClick={() => refetch()}
|
|
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-4 py-2 text-sm font-medium hover:bg-accent transition-colors"
|
|
>
|
|
<RefreshCw className="h-4 w-4" /> Retry
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!stats) {
|
|
return (
|
|
<div className="flex flex-col items-center gap-4 py-20 text-muted-foreground">
|
|
<BarChart3 className="h-10 w-10" />
|
|
<p className="text-sm">No data available yet.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const cards = [
|
|
{
|
|
title: "Total Messages",
|
|
value: stats.total_messages.toLocaleString(),
|
|
icon: MessageSquare,
|
|
color: "text-primary",
|
|
bg: "bg-primary/10",
|
|
},
|
|
{
|
|
title: "Today's Messages",
|
|
value: stats.today_messages.toLocaleString(),
|
|
icon: MessageSquare,
|
|
color: "text-success",
|
|
bg: "bg-success-soft",
|
|
},
|
|
{
|
|
title: "Total Users",
|
|
value: stats.total_users.toLocaleString(),
|
|
icon: Users,
|
|
color: "text-primary",
|
|
bg: "bg-primary-soft",
|
|
},
|
|
{
|
|
title: "Active Users (24h)",
|
|
value: stats.active_users_24h.toLocaleString(),
|
|
icon: UserCheck,
|
|
color: "text-tertiary",
|
|
bg: "bg-tertiary-soft",
|
|
},
|
|
{
|
|
title: "Flagged",
|
|
value: stats.total_flagged.toLocaleString(),
|
|
icon: ShieldAlert,
|
|
color: "text-destructive",
|
|
bg: "bg-destructive/10",
|
|
},
|
|
{
|
|
title: "Clean",
|
|
value: stats.total_clean.toLocaleString(),
|
|
icon: ShieldAlert,
|
|
color: "text-success",
|
|
bg: "bg-success-soft",
|
|
},
|
|
{
|
|
title: "Voice Recordings",
|
|
value: stats.total_voice_recordings.toLocaleString(),
|
|
icon: Mic,
|
|
color: "text-info",
|
|
bg: "bg-info-soft",
|
|
onClick: () => patchUIState({ activeTab: "live" }),
|
|
},
|
|
{
|
|
title: "AI Profiles",
|
|
value: stats.total_profiles.toLocaleString(),
|
|
icon: Users,
|
|
color: "text-warning",
|
|
bg: "bg-warning-soft",
|
|
},
|
|
];
|
|
|
|
return (
|
|
<motion.div
|
|
className="grid gap-6"
|
|
variants={cardStagger}
|
|
initial="initial"
|
|
animate="animate"
|
|
>
|
|
{/* Summary cards grid */}
|
|
<motion.div
|
|
variants={cardItem}
|
|
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
|
|
>
|
|
{cards.map((card) => (
|
|
<Card
|
|
key={card.title}
|
|
className={cn(
|
|
"overflow-hidden",
|
|
card.onClick &&
|
|
"cursor-pointer transition-colors hover:bg-accent/50",
|
|
)}
|
|
onClick={card.onClick}
|
|
>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-medium text-muted-foreground">
|
|
{card.title}
|
|
</p>
|
|
<p className="text-2xl font-bold tracking-tight">
|
|
{card.value}
|
|
</p>
|
|
</div>
|
|
<div className={cn("rounded-xl p-2.5", card.bg)}>
|
|
<card.icon className={cn("h-5 w-5", card.color)} />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</motion.div>
|
|
|
|
{/* Top channels */}
|
|
<motion.div variants={cardItem}>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-primary">Top Channels</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{stats.top_channels.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
No channel data yet.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{stats.top_channels.map((ch) => (
|
|
<div
|
|
key={ch.channel_id}
|
|
className="flex items-center justify-between rounded-lg bg-muted/50 px-3 py-2 text-sm"
|
|
>
|
|
<span className="truncate text-xs text-muted-foreground">
|
|
#{ch.channel_name ?? ch.channel_id}
|
|
</span>
|
|
<span className="ml-2 shrink-0 font-medium">
|
|
{ch.message_count.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
|
|
{/* Moderation overview */}
|
|
<motion.div variants={cardItem}>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-primary">Moderation Queue</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-wrap gap-3">
|
|
<StatusBadge status="pending" />
|
|
<StatusBadge status="processing" />
|
|
<StatusBadge status="error" />
|
|
</div>
|
|
<div className="grid gap-4 sm:grid-cols-3 mt-4">
|
|
<div className="rounded-xl border border-border bg-card p-4 text-center">
|
|
<p className="text-2xl font-bold text-muted-foreground">
|
|
{stats.moderation_overview.pending}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-1">Pending</p>
|
|
</div>
|
|
<div className="rounded-xl border border-border bg-card p-4 text-center">
|
|
<p className="text-2xl font-bold text-warning">
|
|
{stats.moderation_overview.processing}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-1">Processing</p>
|
|
</div>
|
|
<div className="rounded-xl border border-border bg-card p-4 text-center">
|
|
<p className="text-2xl font-bold text-destructive">
|
|
{stats.moderation_overview.error}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-1">Errors</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
function StatsSkeleton() {
|
|
return (
|
|
<div className="grid gap-6">
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
{Array.from({ length: 8 }).map((_, i) => (
|
|
<Card key={i}>
|
|
<CardContent className="p-4">
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-3 w-24" />
|
|
<Skeleton className="h-7 w-16" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|