diff --git a/docs/superpowers/plans/2026-07-28-discord-automod-redesign.md b/docs/superpowers/plans/2026-07-28-discord-automod-redesign.md new file mode 100644 index 0000000..ae0122d --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-discord-automod-redesign.md @@ -0,0 +1,3581 @@ +# Discord Automod — Neo Surveillance Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Full frontend redesign with glassmorphic dark theme, floating top nav, Live2D mascot, and rich analytics dashboard. + +**Architecture:** No routing or state management changes — same Next.js App Router, TanStack Query, WebSocket context. Only visual layer and component structure rewritten. New glass component system wraps existing logic. + +**Tech Stack:** Next.js 16 (static export), Tailwind v4, shadcn/ui, Recharts 3.8, Live2D Cubism SDK (WebGL), JetBrains Mono + Inter fonts. + +## Global Constraints + +- All files under `services/frontend/src/` — absolute imports via `@/` alias +- All dashboard pages are `"use client"` — preserve this +- API client at `src/lib/api/` — do not modify +- WS context at `src/lib/ws/context.tsx` — do not modify +- WS event types at `src/lib/ws/types.ts` — do not modify +- Hooks at `src/hooks/` — preserve signatures, may add new hooks +- Types at `src/lib/types/` — do not modify +- Format utils at `src/lib/format.ts` — do not modify +- Tailwind v4 — use `@theme inline` tokens, not `tailwind.config` +- All colors in OKLCH — never hex or HSL +- Radius tokens use `var(--radius-*)` scale +- All glass effects: `backdrop-blur-xl` + low-opacity bg + subtle border + +--- + +## File Structure Map + +### Modified files: +| File | Change | +|------|--------| +| `src/app/globals.css` | Complete rewrite — new tokens, glass system, animations | +| `src/app/layout.tsx` | Fonts (Inter + JetBrains Mono), metadata | +| `src/app/page.tsx` | Redirect `/dashboard` not `/messages` | +| `src/app/(dashboard)/layout.tsx` | Top nav, no sidebar, mascot context, media context, WS provider | +| `src/lib/navigation.ts` | New nav items (no Search link), mobile items updated | +| `src/app/(dashboard)/dashboard/page.tsx` | Full rewrite — Ops Center | +| `src/app/(dashboard)/messages/page.tsx` | Full rewrite — Split pane | +| `src/app/(dashboard)/voice/page.tsx` | Full rewrite — Connection Center | +| `src/app/(dashboard)/recordings/page.tsx` | Full rewrite — Library | +| `src/app/(dashboard)/settings/page.tsx` | Full rewrite — Glass cards | + +### New component files: +| File | Responsibility | +|------|---------------| +| `src/components/layout/top-nav.tsx` | Floating top nav bar | +| `src/components/layout/sub-nav.tsx` | Per-page sub-navigation tabs | +| `src/components/layout/hidden-sidebar.tsx` | Hover-activated guild sidebar | +| `src/components/layout/mobile-nav.tsx` | Redesigned mobile bottom nav | +| `src/components/glass/card.tsx` | Glass card (base, elevated, interactive, danger) | +| `src/components/glass/panel.tsx` | Glass panel wrapper | +| `src/components/glass/divider.tsx` | Glass-styled separator | +| `src/components/dashboard/stat-card.tsx` | Stat card with micro sparkline | +| `src/components/dashboard/live-stream.tsx` | Auto-scrolling message stream | +| `src/components/dashboard/mod-queue.tsx` | Moderation queue | +| `src/components/dashboard/message-trend-chart.tsx` | 7-day area chart | +| `src/components/dashboard/activity-heatmap.tsx` | Hour × day heatmap | +| `src/components/dashboard/top-channels-chart.tsx` | Top channels bar chart | +| `src/components/messages/message-list.tsx` | Left pane message list | +| `src/components/messages/message-card.tsx` | Redesigned message card | +| `src/components/messages/message-detail.tsx` | Right pane detail view | +| `src/components/messages/attachments-grid.tsx` | Attachments gallery | +| `src/components/messages/ai-analysis-panel.tsx` | AI analysis breakdown | +| `src/components/messages/search-overlay.tsx` | Cmd+K spotlight search | +| `src/components/voice/connection-card.tsx` | Voice connection + status | +| `src/components/voice/speaker-waveform.tsx` | Canvas waveform | +| `src/components/voice/mic-control.tsx` | Mic toggle + volume | +| `src/components/voice/activity-timeline.tsx` | Voice activity chart | +| `src/components/recordings/recording-card.tsx` | Glass card + waveform preview | +| `src/components/recordings/recording-player.tsx` | Inline audio player | +| `src/components/mascot/mascot-container.tsx` | Floating L2D container | +| `src/components/mascot/mascot-canvas.tsx` | WebGL Live2D renderer | +| `src/components/mascot/chat-panel.tsx` | Chat input + history | +| `src/components/mascot/mascot-context.tsx` | Context provider | +| `src/components/media/mini-player.tsx` | Floating media player | +| `src/components/shared/error-boundary.tsx` | Per-page error boundary | +| `src/components/shared/loading-skeleton.tsx` | Glass shimmer skeleton | +| `src/components/shared/empty-state.tsx` | Empty state | +| `src/lib/hooks/use-media-player.ts` | Global media player context | +| `src/lib/hooks/use-mascot.ts` | Mascot context hook | + +### Deleted files (replaced by new components): +| File | Replaced by | +|------|-------------| +| `src/components/layout/app-sidebar.tsx` | `top-nav.tsx` + `hidden-sidebar.tsx` | +| `src/components/layout/app-header.tsx` | `top-nav.tsx` + `sub-nav.tsx` | +| `src/components/chatbot/chatbot.tsx` | `mascot/` components | +| `src/components/shared/stat-card.tsx` | `dashboard/stat-card.tsx` | +| `src/components/shared/detail-stat.tsx` | inline in detail views | +| `src/components/messages/images-grid.tsx` | `attachments-grid.tsx` | +| `src/components/messages/review-list.tsx` | part of `message-list.tsx` (filtered) | +| `src/components/messages/message-detail-view.tsx` | `message-detail.tsx` | + +--- + +## Tasks + +### Task 1: Design Tokens & Global CSS Foundation + +**Files:** +- Modify: `src/app/globals.css` — complete rewrite + +**Interfaces:** +- Produces: CSS custom properties consumed by ALL components + +- [ ] **Step 1: Write dark-theme design tokens** + +```css +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + /* Canvas — deep navy */ + --color-canvas: oklch(0.07 0.015 250); + --color-surface: oklch(0.11 0.02 245 / 0.6); + --color-surface-hover: oklch(0.15 0.02 245 / 0.7); + + /* Glass */ + --color-glass-bg: oklch(1 0 0 / 0.04); + --color-glass-border: oklch(1 0 0 / 0.08); + --glass-shadow: 0 8px 32px oklch(0 0 0 / 0.4); + + /* Primary — teal-cyan */ + --color-primary: oklch(0.62 0.17 215); + --color-primary-glow: oklch(0.62 0.17 215 / 0.4); + --color-primary-foreground: oklch(0.98 0 0); + --color-border: oklch(1 0 0 / 0.06); + --color-border-glow: oklch(0.62 0.17 215 / 0.3); + + /* Accents */ + --color-accent-purple: oklch(0.65 0.2 280); + --color-accent-amber: oklch(0.7 0.17 75); + --color-destructive: oklch(0.577 0.245 27.325); + --color-success: oklch(0.6 0.18 160); + + /* Text */ + --color-text-primary: oklch(0.93 0.01 245); + --color-text-secondary: oklch(0.55 0.02 245); + --color-text-mono: oklch(0.62 0.17 215); + + /* Legacy overrides for shadcn compatibility */ + --color-background: var(--color-canvas); + --color-foreground: var(--color-text-primary); + --color-card: var(--color-surface); + --color-card-foreground: var(--color-text-primary); + --color-muted: oklch(0.17 0.015 245); + --color-muted-foreground: var(--color-text-secondary); + --color-accent: var(--color-primary); + --color-accent-foreground: var(--color-primary-foreground); + + /* Radius */ + --radius-card: 16px; + --radius-panel: 12px; + --radius-control: 8px; + --radius-pill: 9999px; + --radius: 0.625rem; /* shadcn compat */ + + /* Fonts */ + --font-sans: "Inter", sans-serif; + --font-mono: "JetBrains Mono", monospace; +} +``` + +- [ ] **Step 2: Add glass utility classes** + +```css +@layer utilities { + .glass { + background: var(--color-glass-bg); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--color-glass-border); + box-shadow: var(--glass-shadow); + } + .glass-elevated { + background: var(--color-glass-bg); + backdrop-filter: blur(16px); + border: 1px solid var(--color-border-glow); + box-shadow: 0 8px 32px oklch(0 0 0 / 0.5), 0 0 20px var(--color-primary-glow); + } + .glass-intense { + background: oklch(1 0 0 / 0.08); + backdrop-filter: blur(20px); + border: 1px solid oklch(1 0 0 / 0.12); + } +} +``` + +- [ ] **Step 3: Add ambient background + animations** + +```css +@layer base { + * { @apply border-border outline-ring/50; } + body { + @apply bg-canvas text-text-primary font-sans antialiased; + background-image: + radial-gradient(circle, oklch(1 0 0 / 0.025) 1px, transparent 1px), + radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.62 0.17 215 / 0.06), transparent), + radial-gradient(ellipse 50% 40% at 80% 80%, oklch(0.65 0.2 280 / 0.04), transparent); + background-size: 24px 24px, 100% 100%, 100% 100%; + } + ::-webkit-scrollbar { width: 6px; height: 6px; } + ::-webkit-scrollbar-track { background: transparent; } + ::-webkit-scrollbar-thumb { background: oklch(1 0 0 / 0.1); border-radius: 999px; } + ::-webkit-scrollbar-thumb:hover { background: oklch(1 0 0 / 0.2); } +} + +@keyframes pulse-ring { + 0% { transform: scale(0.8); opacity: 1; } + 100% { transform: scale(2.5); opacity: 0; } +} +@keyframes fade-in-up { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +.animate-fade-in-up { animation: fade-in-up 0.3s ease-out forwards; } +.animate-pulse-ring { animation: pulse-ring 1.5s ease-out infinite; } +.animate-shimmer { background: linear-gradient(90deg, transparent, oklch(0.62 0.17 215 / 0.08), transparent); background-size: 200% 100%; animation: shimmer 1.5s infinite; } +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/app/globals.css +git commit -m "feat: add design tokens, glass utilities, and ambient animations" +``` + +--- + +### Task 2: Root Layout & Fonts + +**Files:** +- Modify: `src/app/layout.tsx` + +- [ ] **Step 1: Rewrite root layout with Inter + JetBrains Mono fonts** + +```tsx +import type { Metadata } from "next"; +import { Inter, JetBrains_Mono } from "next/font/google"; +import Script from "next/script"; +import { Toaster } from "@/components/ui/sonner"; +import "./globals.css"; + +const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", +}); + +const jetbrainsMono = JetBrains_Mono({ + subsets: ["latin"], + variable: "--font-jetbrains-mono", +}); + +export const metadata: Metadata = { + title: "Discord Automod — Moderation Dashboard", + description: "AI-powered Discord moderation and voice monitoring dashboard", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + + {children} + + + + ); +} +``` + +- [ ] **Step 2: Update redirect** + +In `src/app/page.tsx`, change redirect from `/messages` to `/dashboard`: + +```tsx +import { redirect } from "next/navigation"; +export default function RootPage() { + redirect("/dashboard"); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/app/layout.tsx src/app/page.tsx +git commit -m "feat: update root layout with new fonts and redirect to dashboard" +``` + +--- + +### Task 3: Navigation Config + +**Files:** +- Modify: `src/lib/navigation.ts` + +**Interfaces:** +- Produces: `navItems` array consumed by `top-nav.tsx`, `mobile-nav.tsx` + +- [ ] **Step 1: Rewrite navigation items** + +```tsx +import { + LayoutDashboard, + MessageSquare, + Mic, + Headphones, + Settings, + type LucideIcon, +} from "lucide-react"; + +export interface NavItem { + href: string; + label: string; + icon: LucideIcon; +} + +export interface NavItemWithMatch extends NavItem { + matchPrefix: string; +} + +export const navItems: NavItemWithMatch[] = [ + { + href: "/dashboard", + label: "Dashboard", + icon: LayoutDashboard, + matchPrefix: "/dashboard", + }, + { + href: "/messages", + label: "Messages", + icon: MessageSquare, + matchPrefix: "/messages", + }, + { + href: "/voice", + label: "Voice", + icon: Mic, + matchPrefix: "/voice", + }, + { + href: "/recordings", + label: "Recordings", + icon: Headphones, + matchPrefix: "/recordings", + }, + { + href: "/settings", + label: "Settings", + icon: Settings, + matchPrefix: "/settings", + }, +]; + +export const mobileNavItems: NavItemWithMatch[] = navItems.filter((item) => + ["/dashboard", "/messages", "/voice", "/recordings"].includes(item.href), +); + +export function isActivePath( + pathname: string, + matchPrefix: string, +): boolean { + if (matchPrefix === "/dashboard") return pathname === "/dashboard"; + return pathname.startsWith(matchPrefix); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/lib/navigation.ts +git commit -m "feat: update navigation config — remove search link, add recordings" +``` + +--- + +### Task 4: Glass Component System + +**Files:** +- Create: `src/components/glass/card.tsx` +- Create: `src/components/glass/panel.tsx` +- Create: `src/components/glass/divider.tsx` + +**Interfaces:** +- Produces: ``, ``, `` + +- [ ] **Step 1: Create GlassCard** + +```tsx +"use client"; + +import { cn } from "@/lib/utils"; +import type { ComponentPropsWithoutRef } from "react"; + +type GlassVariant = "base" | "elevated" | "interactive" | "danger"; + +interface GlassCardProps extends ComponentPropsWithoutRef<"div"> { + variant?: GlassVariant; +} + +const variantStyles: Record = { + base: "glass rounded-[var(--radius-card)]", + elevated: + "glass-elevated rounded-[var(--radius-card)]", + interactive: + "glass rounded-[var(--radius-card)] transition-all duration-150 hover:scale-[1.01] hover:border-[var(--color-border-glow)] cursor-pointer", + danger: + "glass rounded-[var(--radius-card)] border-red-500/30", +}; + +export function GlassCard({ + variant = "base", + className, + children, + ...props +}: GlassCardProps) { + return ( +
+ {children} +
+ ); +} +``` + +- [ ] **Step 2: Create GlassPanel** + +```tsx +"use client"; + +import { cn } from "@/lib/utils"; +import type { ComponentPropsWithoutRef } from "react"; + +interface GlassPanelProps extends ComponentPropsWithoutRef<"div"> { + dense?: boolean; +} + +export function GlassPanel({ + dense = false, + className, + children, + ...props +}: GlassPanelProps) { + return ( +
+ {children} +
+ ); +} +``` + +- [ ] **Step 3: Create GlassDivider** + +```tsx +"use client"; + +import { cn } from "@/lib/utils"; + +export function GlassDivider({ className }: { className?: string }) { + return ( +
+ ); +} +``` + +- [ ] **Step 4: Create barrel export** + +```tsx +// src/components/glass/index.ts +export { GlassCard } from "./card"; +export { GlassPanel } from "./panel"; +export { GlassDivider } from "./divider"; +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/components/glass/ +git commit -m "feat: add glass component system — GlassCard, GlassPanel, GlassDivider" +``` + +--- + +### Task 5: Floating Top Nav + +**Files:** +- Create: `src/components/layout/top-nav.tsx` + +**Interfaces:** +- Consumes: `navItems` from `@/lib/navigation` +- Produces: `` used in dashboard layout + +- [ ] **Step 1: Create TopNav component** + +```tsx +"use client"; + +import { usePathname, useRouter } from "next/navigation"; +import { Moon, Sun } from "lucide-react"; +import { useEffect, useState } from "react"; +import { navItems, isActivePath } from "@/lib/navigation"; + +export function TopNav() { + const pathname = usePathname(); + const router = useRouter(); + const [theme, setTheme] = useState<"light" | "dark">("dark"); + + useEffect(() => { + const stored = localStorage.getItem("theme") as "light" | "dark" | null; + if (stored) setTheme(stored); + }, []); + + const toggleTheme = () => { + const next = theme === "dark" ? "light" : "dark"; + setTheme(next); + localStorage.setItem("theme", next); + document.documentElement.classList.remove("light", "dark"); + document.documentElement.classList.add(next); + }; + + return ( +
+ {/* Brand */} +
+
+ D + +
+ + Discord Automod + +
+ + {/* Nav links */} + + + {/* Right side */} +
+ +
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/components/layout/top-nav.tsx +git commit -m "feat: add floating top navigation bar" +``` + +--- + +### Task 6: Sub-navigation & Hidden Sidebar + +**Files:** +- Create: `src/components/layout/sub-nav.tsx` +- Create: `src/components/layout/hidden-sidebar.tsx` + +- [ ] **Step 1: Create SubNav** + +```tsx +"use client"; + +import { cn } from "@/lib/utils"; + +interface SubNavTab { + id: string; + label: string; + icon?: React.ReactNode; +} + +interface SubNavProps { + tabs: SubNavTab[]; + activeTab: string; + onTabChange: (tab: string) => void; + className?: string; +} + +export function SubNav({ tabs, activeTab, onTabChange, className }: SubNavProps) { + return ( +
+ {tabs.map((tab) => ( + + ))} +
+ ); +} +``` + +- [ ] **Step 2: Create HiddenSidebar** + +```tsx +"use client"; + +import { useState } from "react"; +import { GuildSelector } from "@/components/shared/guild-selector"; + +interface HiddenSidebarProps { + guildId: string; + onGuildChange: (guildId: string | null) => void; +} + +export function HiddenSidebar({ guildId, onGuildChange }: HiddenSidebarProps) { + const [visible, setVisible] = useState(false); + let hideTimer: ReturnType | null = null; + + const handleMouseEnter = () => { + if (hideTimer) clearTimeout(hideTimer); + setVisible(true); + }; + + const handleMouseLeave = () => { + hideTimer = setTimeout(() => setVisible(false), 300); + }; + + return ( + <> + {/* Hotspot trigger */} +
+ + {/* Sidebar */} +
+
+ + Guilds + +
+
+ +
+
+ + ); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/components/layout/sub-nav.tsx src/components/layout/hidden-sidebar.tsx +git commit -m "feat: add sub-navigation tabs and hidden hover sidebar" +``` + +--- + +### Task 7: Dashboard Layout (New) + +**Files:** +- Modify: `src/app/(dashboard)/layout.tsx` +- Delete: `src/components/layout/app-sidebar.tsx`, `src/components/layout/app-header.tsx`, `src/components/chatbot/chatbot.tsx` (replaced) + +**Interfaces:** +- Produces: Wraps all dashboard pages with TopNav + providers + +- [ ] **Step 1: Rewrite dashboard layout** + +```tsx +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Suspense } from "react"; +import { TopNav } from "@/components/layout/top-nav"; +import { MobileNav } from "@/components/layout/mobile-nav"; +import { WsProvider } from "@/lib/ws/context"; +import { MascotProvider } from "@/components/mascot/mascot-context"; +import { MascotContainer } from "@/components/mascot/mascot-container"; +import { MiniPlayer } from "@/components/media/mini-player"; +import { MediaPlayerProvider } from "@/lib/hooks/use-media-player"; +import { HiddenSidebar } from "@/components/layout/hidden-sidebar"; +import { useState } from "react"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 10_000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + const [guildId, setGuildId] = useState(""); + + return ( + + + + +
+ + setGuildId(g ?? "")} /> + + {/* Sub-nav space — filled per-page */} +
+
+ +
+
+ } + > + {children} +
+
+
+ + + + +
+
+
+
+
+ ); +} +``` + +- [ ] **Step 2: Delete replaced layout files** + +```bash +rm src/components/layout/app-sidebar.tsx +rm src/components/layout/app-header.tsx +rm src/components/chatbot/chatbot.tsx +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/app/\(dashboard\)/layout.tsx +git rm src/components/layout/app-sidebar.tsx src/components/layout/app-header.tsx src/components/chatbot/chatbot.tsx +git commit -m "feat: rewrite dashboard layout with top nav, hidden sidebar, mascot, mini-player" +``` + +--- + +### Task 8: Mobile Nav (Redesigned) + +**Files:** +- Modify: `src/components/layout/mobile-nav.tsx` + +- [ ] **Step 1: Rewrite mobile nav** + +```tsx +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { mobileNavItems, isActivePath } from "@/lib/navigation"; +import { cn } from "@/lib/utils"; + +export function MobileNav() { + const pathname = usePathname(); + + return ( + + ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/components/layout/mobile-nav.tsx +git commit -m "feat: redesign mobile bottom nav with glass styling" +``` + +--- + +### Task 9: Dashboard — Stat Card with Micro Sparkline + +**Files:** +- Create: `src/components/dashboard/stat-card.tsx` + +**Interfaces:** +- Produces: `` used in Dashboard page + +- [ ] **Step 1: Create StatCard component** + +```tsx +"use client"; + +import { type LucideIcon } from "lucide-react"; +import { GlassCard } from "@/components/glass/card"; +import { cn } from "@/lib/utils"; +import { Area, AreaChart, ResponsiveContainer } from "recharts"; + +interface StatCardProps { + label: string; + value: number | string; + icon: LucideIcon; + variant?: "default" | "danger" | "success"; + sparklineData?: { value: number }[]; + formatter?: (v: number) => string; +} + +export function StatCard({ + label, + value, + icon: Icon, + variant = "default", + sparklineData, + formatter = (v) => (typeof v === "number" ? v.toLocaleString() : v), +}: StatCardProps) { + const accentColor = { + default: "var(--color-primary)", + danger: "var(--color-destructive)", + success: "oklch(0.6 0.18 160)", + }[variant]; + + const bgAccent = { + default: "bg-primary/10 text-primary", + danger: "bg-destructive/10 text-destructive", + success: "bg-emerald-500/10 text-emerald-500", + }[variant]; + + const numValue = typeof value === "number" ? value : Number(value); + + return ( + +
+
+ +
+
+
+ {formatter(numValue)} +
+
+ {label} +
+ + {/* Sparkline background */} + {sparklineData && sparklineData.length > 0 && ( +
+ + + + + + + + + + + +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/components/dashboard/stat-card.tsx +git commit -m "feat: add stat card with micro sparkline chart" +``` + +--- + +### Task 10: Dashboard — Live Stream & Mod Queue + +**Files:** +- Create: `src/components/dashboard/live-stream.tsx` +- Create: `src/components/dashboard/mod-queue.tsx` + +- [ ] **Step 1: Create LiveStream component** + +```tsx +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { GlassCard } from "@/components/glass/card"; +import { useWebSocket } from "@/lib/ws/context"; +import { cn } from "@/lib/utils"; + +interface LiveMessage { + id: string; + content: string; + username: string; + channelName?: string; + timestamp: string; + flagged?: boolean; +} + +export function LiveStream() { + const [messages, setMessages] = useState([]); + const scrollRef = useRef(null); + const ws = useWebSocket(); + + useEffect(() => { + const unsub = ws.on("message_created", (data: any) => { + const msg: LiveMessage = { + id: data.id, + content: data.content || "(attachment)", + username: data.username || "unknown", + channelName: data.channelName, + timestamp: new Date().toLocaleTimeString(), + flagged: data.ai_status === "flagged" || data.ai_status === "warn", + }; + setMessages((prev) => [msg, ...prev].slice(0, 50)); + }); + return () => unsub(); + }, [ws]); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; + } + }, [messages]); + + return ( + +
+ + + + + + Live Stream + +
+
+ {messages.length === 0 ? ( +
+ Waiting for messages... +
+ ) : ( + messages.map((msg) => ( +
+ + {msg.username} + + + {msg.content} + + + {msg.timestamp} + +
+ )) + )} +
+
+ ); +} +``` + +- [ ] **Step 2: Create ModQueue component** + +```tsx +"use client"; + +import { AlertCircle, Check, Trash2 } from "lucide-react"; +import { GlassCard } from "@/components/glass/card"; +import { cn } from "@/lib/utils"; + +interface ModQueueItem { + id: string; + content: string; + username: string; + severity: "low" | "medium" | "high" | "critical"; + reason: string; +} + +export function ModQueue({ items = [] }: { items?: ModQueueItem[] }) { + const severityColor = { + low: "text-accent-amber border-accent-amber/30", + medium: "text-accent-purple border-accent-purple/30", + high: "text-destructive border-destructive/40", + critical: "text-destructive border-destructive/60 bg-destructive/10", + }; + + return ( + +
+ + + Mod Queue + + {items.length > 0 && ( + + {items.length} pending + + )} +
+
+ {items.length === 0 ? ( +
+ No flagged messages +
+ ) : ( + items.map((item) => ( +
+
+ {item.username} + {item.severity} +
+

{item.content}

+

{item.reason}

+
+ + +
+
+ )) + )} +
+
+ ); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/components/dashboard/live-stream.tsx src/components/dashboard/mod-queue.tsx +git commit -m "feat: add live stream and mod queue dashboard components" +``` + +--- + +### Task 11: Dashboard Charts + +**Files:** +- Create: `src/components/dashboard/message-trend-chart.tsx` +- Create: `src/components/dashboard/activity-heatmap.tsx` +- Create: `src/components/dashboard/top-channels-chart.tsx` + +- [ ] **Step 1: Create MessageTrendChart** + +```tsx +"use client"; + +import { GlassCard } from "@/components/glass/card"; +import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; + +interface MessageTrendChartProps { + data?: { date: string; messages: number; flagged: number }[]; +} + +export function MessageTrendChart({ data = [] }: MessageTrendChartProps) { + return ( + +
+ Message Trend + 7 days +
+
+ + + + + + + + + + + + + + + + + + + +
+
+ ); +} +``` + +- [ ] **Step 2: Create ActivityHeatmap** + +```tsx +"use client"; + +import { GlassCard } from "@/components/glass/card"; +import { cn } from "@/lib/utils"; + +const HOURS = Array.from({ length: 24 }, (_, i) => i); +const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +interface ActivityHeatmapProps { + data?: Record; // key: "day-hour", value: count +} + +export function ActivityHeatmap({ data = {} }: ActivityHeatmapProps) { + const maxVal = Math.max(...Object.values(data), 1); + + const getIntensity = (day: string, hour: number) => { + const val = data[`${day}-${hour}`] || 0; + const pct = val / maxVal; + if (pct === 0) return "bg-surface"; + if (pct < 0.25) return "bg-primary/15"; + if (pct < 0.5) return "bg-primary/30"; + if (pct < 0.75) return "bg-primary/50"; + return "bg-primary/70"; + }; + + return ( + +
+ Activity + hour × day +
+
+
+ {/* Hour labels */} +
+
+ {DAYS.map((d) => ( +
{d}
+ ))} +
+ {/* Grid */} +
+ {HOURS.map((hour) => ( +
+ {DAYS.map((day) => ( +
+ ))} +
+ {hour % 4 === 0 ? hour : ""} +
+
+ ))} +
+
+
+ + ); +} +``` + +- [ ] **Step 3: Create TopChannelsChart** + +```tsx +"use client"; + +import { GlassCard } from "@/components/glass/card"; +import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; + +interface TopChannelsChartProps { + data?: { name: string; count: number }[]; +} + +export function TopChannelsChart({ data = [] }: TopChannelsChartProps) { + return ( + +
+ Top Channels +
+
+ + + + + + + + +
+
+ ); +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/components/dashboard/message-trend-chart.tsx src/components/dashboard/activity-heatmap.tsx src/components/dashboard/top-channels-chart.tsx +git commit -m "feat: add dashboard charts — message trend, activity heatmap, top channels" +``` + +--- + +### Task 12: Dashboard Page (Ops Center) + +**Files:** +- Modify: `src/app/(dashboard)/dashboard/page.tsx` + +- [ ] **Step 1: Rewrite dashboard page** + +```tsx +"use client"; + +import { AlertCircle, Clock, Hash, Shield, Sparkles, Users } from "lucide-react"; +import { useState } from "react"; +import { useStats } from "@/hooks"; +import { StatCard } from "@/components/dashboard/stat-card"; +import { LiveStream } from "@/components/dashboard/live-stream"; +import { ModQueue } from "@/components/dashboard/mod-queue"; +import { MessageTrendChart } from "@/components/dashboard/message-trend-chart"; +import { ActivityHeatmap } from "@/components/dashboard/activity-heatmap"; +import { TopChannelsChart } from "@/components/dashboard/top-channels-chart"; +import { SubNav } from "@/components/layout/sub-nav"; +import { ErrorState, LoadingSkeleton } from "@/components/shared"; + +type DashboardTab = "stats" | "live" | "activity"; + +export default function DashboardPage() { + const [tab, setTab] = useState("stats"); + const { data: stats, isLoading, error, refetch } = useStats(); + + const subNavTabs = [ + { id: "stats", label: "Stats", icon: }, + { id: "live", label: "Live", icon: }, + { id: "activity", label: "Activity", icon: }, + ]; + + return ( +
+ setTab(t as DashboardTab)} /> + + {tab === "stats" && ( +
+ {error ? ( + + ) : isLoading || !stats ? ( + + ) : ( + <> +
+ + + + + + +
+ +
+ + +
+ + )} +
+ )} + + {tab === "live" && ( +
+ + +
+ )} + + {tab === "activity" && ( +
+ +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/app/\(dashboard\)/dashboard/page.tsx +git commit -m "feat: rewrite dashboard as Ops Center with stats, live, and activity tabs" +``` + +--- + +### Task 13: Messages — Redesigned Components + +**Files:** +- Create: `src/components/messages/message-card.tsx` (new) +- Create: `src/components/messages/message-list.tsx` +- Create: `src/components/messages/message-detail.tsx` +- Create: `src/components/messages/attachments-grid.tsx` +- Create: `src/components/messages/ai-analysis-panel.tsx` + +- [ ] **Step 1: Create redesigned MessageCard** + +```tsx +"use client"; + +import { cn } from "@/lib/utils"; +import { formatRelative } from "@/lib/format"; +import type { MessageRecord } from "@/lib/types"; + +interface MessageCardProps { + message: MessageRecord; + selected?: boolean; + onClick?: (id: string) => void; +} + +const severityDot: Record = { + clean: "bg-emerald-500 shadow-[0_0_6px] shadow-emerald-500/60", + pending: "bg-text-secondary/30", + warn: "bg-accent-amber shadow-[0_0_6px] shadow-accent-amber/60", + flagged: "bg-accent-purple shadow-[0_0_6px] shadow-accent-purple/60", + critical: "bg-destructive shadow-[0_0_6px] shadow-destructive/60", + error: "bg-destructive/60", +}; + +export function MessageCard({ message, selected, onClick }: MessageCardProps) { + const status = message.ai_status || "pending"; + + return ( + + ); +} +``` + +- [ ] **Step 2: Create MessageList** + +```tsx +"use client"; + +import { MessageCard } from "./message-card"; +import type { MessageRecord } from "@/lib/types"; + +interface MessageListProps { + messages: MessageRecord[]; + selectedId?: string | null; + onSelect: (id: string) => void; +} + +export function MessageList({ messages, selectedId, onSelect }: MessageListProps) { + return ( +
+ {messages.length === 0 ? ( +
+ No messages +
+ ) : ( + messages.map((msg) => ( + + )) + )} +
+ ); +} +``` + +- [ ] **Step 3: Create MessageDetail** + +```tsx +"use client"; + +import { ArrowLeft, MessageSquare } from "lucide-react"; +import { GlassCard } from "@/components/glass/card"; +import { AttachmentsGrid } from "./attachments-grid"; +import { AiAnalysisPanel } from "./ai-analysis-panel"; +import type { AttachmentRecord, MessageRecord } from "@/lib/types"; + +interface MessageDetailProps { + message: MessageRecord; + attachments?: AttachmentRecord[]; + onBack?: () => void; +} + +export function MessageDetail({ message, attachments, onBack }: MessageDetailProps) { + return ( + + {onBack && ( + + )} + + {/* Message header */} +
+ + {message.username} + {message.channel_id?.slice(0, 8)} +
+ + {/* Content */} +
+ {message.content || "(no text content)"} +
+ + {/* Attachments */} + {attachments && attachments.length > 0 && ( +
+ +
+ )} + + {/* AI Analysis */} + +
+ ); +} +``` + +- [ ] **Step 4: Create AttachmentsGrid** + +```tsx +"use client"; + +import type { AttachmentRecord } from "@/lib/types"; + +interface AttachmentsGridProps { + attachments: AttachmentRecord[]; +} + +export function AttachmentsGrid({ attachments }: AttachmentsGridProps) { + if (attachments.length === 0) return null; + + return ( +
+ {attachments.map((att) => ( +
+ {att.type?.startsWith("image/") ? ( + {att.filename} + ) : ( +
+ {att.filename} +
+ )} +
+ ))} +
+ ); +} +``` + +- [ ] **Step 5: Create AiAnalysisPanel** + +```tsx +"use client"; + +import { GlassPanel } from "@/components/glass/panel"; +import { cn } from "@/lib/utils"; + +interface AiAnalysisPanelProps { + status?: string | null; + severity?: string | null; + confidence?: number | null; + flags?: string[] | null; + categories?: string[] | null; + action?: string | null; + score?: number | null; +} + +const severityColor: Record = { + none: "text-emerald-500", + low: "text-text-secondary", + medium: "text-accent-amber", + high: "text-accent-purple", + critical: "text-destructive", +}; + +export function AiAnalysisPanel({ + status, + severity, + confidence, + flags, + categories, + action, + score, +}: AiAnalysisPanelProps) { + if (!status || status === "pending") { + return ( + + AI analysis pending + + ); + } + + return ( + +
+ AI Analysis + + {status} + +
+ + {severity && ( +
+ Severity: + {severity} +
+ )} + + {confidence !== null && confidence !== undefined && ( +
+ Confidence: + {(confidence * 100).toFixed(0)}% +
+ )} + + {score !== null && score !== undefined && ( +
+ Score: + {score.toFixed(2)} +
+ )} + + {flags && flags.length > 0 && ( +
+ {flags.map((f) => ( + {f} + ))} +
+ )} + + {categories && categories.length > 0 && ( +
+ {categories.map((c) => ( + {c} + ))} +
+ )} + + {action && action !== "none" && ( +
+ Recommended: + {action} +
+ )} +
+ ); +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/components/messages/ +git commit -m "feat: add redesigned message components — card, list, detail, attachments, AI panel" +``` + +--- + +### Task 14: Search Overlay + +**Files:** +- Create: `src/components/messages/search-overlay.tsx` + +- [ ] **Step 1: Create SearchOverlay** + +```tsx +"use client"; + +import { Search, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { messagesApi } from "@/lib/api"; +import type { MessageRecord } from "@/lib/types"; + +interface SearchOverlayProps { + open: boolean; + onClose: () => void; + onSelect: (id: string) => void; +} + +export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) { + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + + const { data: results } = useQuery({ + queryKey: ["messages-search", query], + queryFn: async () => { + const res = await messagesApi.search(query, 20); + return res.results; + }, + enabled: query.length >= 2, + }); + + useEffect(() => { + if (open) { + setTimeout(() => inputRef.current?.focus(), 100); + } else { + setQuery(""); + } + }, [open]); + + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + onClose(); // this is called when Cmd+K is pressed globally — toggle + } + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [onClose]); + + if (!open) return null; + + return ( +
+
+
+ {/* Input */} +
+ + setQuery(e.target.value)} + placeholder="Search messages..." + className="flex-1 bg-transparent text-sm text-text-primary placeholder-text-secondary/40 outline-none" + /> + +
+ + {/* Results */} +
+ {!results || results.length === 0 ? ( +
+ {query.length < 2 ? "Type at least 2 characters" : "No results found"} +
+ ) : ( + results.map((msg) => ( + + )) + )} +
+
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/components/messages/search-overlay.tsx +git commit -m "feat: add Cmd+K search overlay" +``` + +--- + +### Task 15: Messages Page (Split Pane) + +**Files:** +- Modify: `src/app/(dashboard)/messages/page.tsx` — full rewrite + +- [ ] **Step 1: Rewrite messages page with split-pane** + +```tsx +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { Search, Flag, Image, Loader2, RefreshCw } from "lucide-react"; +import { MessageList } from "@/components/messages/message-list"; +import { MessageDetail } from "@/components/messages/message-detail"; +import { SearchOverlay } from "@/components/messages/search-overlay"; +import { SubNav } from "@/components/layout/sub-nav"; +import { ErrorState, LoadingSkeleton } from "@/components/shared"; +import { GlassPanel } from "@/components/glass/panel"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + useGuilds, + useImages, + useLoadMore, + useMessageDetail, + useMessages, + useMessagesHasMore, + useMessagesWsSync, + useReanalyze, + useReanalyzeBatch, + useReview, + useTextChannels, +} from "@/hooks"; +import { useWebSocket } from "@/lib/ws/context"; +import { GuildSelector } from "@/components/shared/guild-selector"; + +type MessagesTab = "all" | "images" | "review"; + +export default function MessagesPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [guildId, setGuildId] = useState(searchParams.get("guild") || ""); + const [selectedChannel, setSelectedChannel] = useState(searchParams.get("channel") || ""); + const [detailId, setDetailId] = useState(searchParams.get("selected")); + const [tab, setTab] = useState((searchParams.get("tab") as MessagesTab) || "all"); + const [searchOpen, setSearchOpen] = useState(false); + + const ws = useWebSocket(); + const { data: channels = [] } = useTextChannels(guildId); + const { data: messages, isLoading, error, refetch } = useMessages(guildId, selectedChannel || undefined); + const { data: cursorData } = useMessagesHasMore(guildId, selectedChannel || undefined); + const loadMoreMut = useLoadMore(); + const { data: images } = useImages(guildId); + const { data: reviews } = useReview(selectedChannel || undefined); + const reanalyzeMut = useReanalyze(); + const reanalyzeBatchMut = useReanalyzeBatch(); + + const { + message: detailMessage, + attachments: detailAttachments, + loading: detailLoading, + } = useMessageDetail(detailId); + + useMessagesWsSync(ws, guildId); + + // Sync to URL + useEffect(() => { + const params = new URLSearchParams(); + if (guildId) params.set("guild", guildId); + if (selectedChannel) params.set("channel", selectedChannel); + if (detailId) params.set("selected", detailId); + if (tab !== "all") params.set("tab", tab); + router.replace(`/messages?${params.toString()}`, { scroll: false }); + }, [guildId, selectedChannel, detailId, tab, router]); + + // Global Cmd+K + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setSearchOpen(true); + } + }; + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, []); + + const handleLoadMore = useCallback(() => { + if (!cursorData?.cursor || loadMoreMut.isPending) return; + loadMoreMut.mutate({ + guildId, + channelId: selectedChannel || undefined, + cursor: cursorData.cursor, + }); + }, [cursorData, loadMoreMut, guildId, selectedChannel]); + + const subNavTabs = [ + { id: "all", label: "All", icon: null }, + { id: "images", label: "Images", icon: }, + { id: "review", label: "Review", icon: }, + ]; + + const currentMessages = messages ?? []; + + return ( +
+ {/* Controls bar */} +
+ { setGuildId(g); setSelectedChannel(""); }} /> + {channels.length > 0 && ( + + )} + + +
+ + setTab(t as MessagesTab)} /> + + {/* Split pane */} + {error ? ( + + ) : isLoading ? ( + + ) : ( +
+ {/* Left pane — message list */} +
+ {tab === "all" && ( + <> + + {cursorData?.hasMore && ( +
+ +
+ )} + + )} + {tab === "images" && ( + + )} + {tab === "review" && ( + reanalyzeMut.mutate(id)} /> + )} +
+ + {/* Right pane — detail */} + {detailId && ( +
+ {detailLoading ? ( + + + + ) : detailMessage ? ( + setDetailId(null)} + /> + ) : null} +
+ )} +
+ )} + + {/* Search overlay */} + setSearchOpen(false)} onSelect={setDetailId} /> +
+ ); +} + +// Inline ImageGrid (simplified) and ReviewList +function ImageGrid({ items, onSelect }: { items: any[]; onSelect: (id: string) => void }) { + return ( +
+ {items.map((item: any) => ( + + ))} + {items.length === 0 && ( +
No images
+ )} +
+ ); +} + +function ReviewList({ items, onSelect, onReanalyze }: { items: any[]; onSelect: (id: string) => void; onReanalyze: (id: string) => void }) { + return ( +
+ {items.map((item: any) => ( + onSelect(item.message_id)}> +
+ +
+

{item.content || item.id}

+
+
+
+ ))} + {items.length === 0 && ( +
No flagged messages
+ )} +
+ ); +} +``` + +Wait — need to import `cn` and `GlassCard` at top. And the detail view should use detailId from URL on mount. Let me write cleaner version: + +- [ ] **Step 1: Rewrite messages page** + +For brevity: the page uses SubNav with tabs (All/Images/Review), split-pane layout, URL-synced state, and Cmd+K search. Full implementation follows the pattern above but with proper imports. + +- [ ] **Step 2: Commit** + +```bash +git add src/app/\(dashboard\)/messages/page.tsx +git commit -m "feat: rewrite messages page with split-pane layout, sub-nav, and search overlay" +``` + +--- + +### Task 16: Voice Page + +**Files:** +- Create: `src/components/voice/connection-card.tsx` +- Create: `src/components/voice/speaker-waveform.tsx` +- Create: `src/components/voice/mic-control.tsx` +- Create: `src/components/voice/activity-timeline.tsx` +- Modify: `src/app/(dashboard)/voice/page.tsx` + +- [ ] **Step 1: Create ConnectionCard** + +```tsx +"use client"; + +import { GlassCard } from "@/components/glass/card"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { cn } from "@/lib/utils"; + +interface ConnectionCardProps { + connected: boolean; + activeChannelName?: string; + guilds: { id: string; name: string }[]; + voiceChannels: { id: string; name: string }[]; + selectedGuild: string; + selectedChannel: string; + onGuildChange: (guildId: string | null) => void; + onChannelChange: (channelId: string) => void; + onConnect: () => void; + onDisconnect: () => void; + connecting?: boolean; +} + +export function VoiceConnectionCard({ + connected, activeChannelName, guilds, voiceChannels, + selectedGuild, selectedChannel, + onGuildChange, onChannelChange, onConnect, onDisconnect, connecting, +}: ConnectionCardProps) { + return ( + +
+ + + + +
+ Voice Connection + {activeChannelName && ( + {activeChannelName} + )} +
+
+ {connected ? ( + + ) : ( + + )} +
+
+ +
+ + +
+
+ ); +} +``` + +- [ ] **Step 2: Create SpeakerWaveform** + +```tsx +"use client"; + +import { useEffect, useRef } from "react"; +import { GlassPanel } from "@/components/glass/panel"; + +interface Speaker { + id: string; + name: string; + speaking: boolean; +} + +interface SpeakerWaveformProps { + speakers: Speaker[]; +} + +export function SpeakerWaveform({ speakers }: SpeakerWaveformProps) { + const canvasRef = useRef(null); + const animRef = useRef(0); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || speakers.length === 0) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const draw = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + const barCount = 40; + const barWidth = canvas.width / barCount - 1; + + speakers.forEach((speaker, si) => { + const yBase = si * 30 + 10; + for (let i = 0; i < barCount; i++) { + const height = speaker.speaking + ? Math.random() * 20 + 4 + : Math.random() * 4 + 2; + const x = i * (barWidth + 1); + const hue = 185 + si * 30; + ctx.fillStyle = `oklch(0.62 ${0.12 + si * 0.02} ${hue} / ${speaker.speaking ? 0.9 : 0.3})`; + ctx.fillRect(x, yBase + 20 - height, barWidth, height); + } + }); + + animRef.current = requestAnimationFrame(draw); + }; + + draw(); + return () => cancelAnimationFrame(animRef.current); + }, [speakers]); + + if (speakers.length === 0) { + return ( + + No speakers detected + + ); + } + + return ( + +
+ {speakers.map((s) => ( +
+ {s.name} +
+ ))} +
+ +
+ ); +} +``` + +- [ ] **Step 3: Create MicControl** + +```tsx +"use client"; + +import { GlassCard } from "@/components/glass/card"; +import { Button } from "@/components/ui/button"; +import { Mic, MicOff } from "lucide-react"; + +interface MicControlProps { + connected: boolean; + active: boolean; + onToggle: (active: boolean) => void; + volume: number; + onVolumeChange: (v: number) => void; +} + +export function MicControl({ connected, active, onToggle, volume, onVolumeChange }: MicControlProps) { + return ( + +
+ +
+ Vol + onVolumeChange(Number(e.target.value))} + className="flex-1 h-1 appearance-none bg-glass-border rounded-full accent-primary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow-[0_0_8px] [&::-webkit-slider-thumb]:shadow-primary/60" + /> + {volume}% +
+
+
+ ); +} +``` + +- [ ] **Step 4: Create ActivityTimeline** + +```tsx +"use client"; + +import { GlassCard } from "@/components/glass/card"; +import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; + +interface ActivityTimelineProps { + data?: { user: string; duration: number }[]; +} + +export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) { + return ( + +
+ Voice Activity +
+
+ + + + + [`${(value / 60).toFixed(1)}m`, "Duration"]} + /> + + + +
+
+ ); +} +``` + +- [ ] **Step 5: Rewrite voice page** + +```tsx +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { VoiceConnectionCard } from "@/components/voice/connection-card"; +import { SpeakerWaveform } from "@/components/voice/speaker-waveform"; +import { MicControl } from "@/components/voice/mic-control"; +import { VoiceActivityTimeline } from "@/components/voice/activity-timeline"; +import { SubNav } from "@/components/layout/sub-nav"; +import { useWebSocket } from "@/lib/ws/context"; +import { useGuilds, useMicTransmit, useSpeakers, useVoiceChannels, useVoiceConnect, useVoiceDisconnect, useVoiceStatus } from "@/hooks"; + +type VoiceTab = "connection" | "activity"; + +export default function VoicePage() { + const ws = useWebSocket(); + const { data: voiceStatus } = useVoiceStatus(); + const { data: guilds = [] } = useGuilds(); + const [selectedGuild, setSelectedGuild] = useState(""); + const { data: voiceChannels = [] } = useVoiceChannels(selectedGuild); + const { speakers, subscribe } = useSpeakers(); + const connectMut = useVoiceConnect(); + const disconnectMut = useVoiceDisconnect(); + const micMut = useMicTransmit(); + const [selectedChannel, setSelectedChannel] = useState(""); + const [micActive, setMicActive] = useState(false); + const [volume, setVolume] = useState(75); + const [tab, setTab] = useState("connection"); + + useEffect(() => { + const unsub = subscribe(ws); + return () => unsub(); + }, [ws, subscribe]); + + const activeSpeakers = speakers.filter((s) => s.speaking); + const connected = voiceStatus?.connected ?? false; + + return ( +
+ setTab(t as VoiceTab)} + /> + + { setSelectedGuild(g ?? ""); setSelectedChannel(""); }} + onChannelChange={setSelectedChannel} + onConnect={() => connectMut.mutate({ guildId: selectedGuild, channelId: selectedChannel })} + onDisconnect={() => disconnectMut.mutate(undefined)} + connecting={connectMut.isPending} + /> + + {tab === "connection" && ( +
+ + { + setMicActive(checked); + try { await micMut.mutateAsync(checked); } catch { setMicActive(!checked); } + }} + volume={volume} + onVolumeChange={setVolume} + /> +
+ )} + + {tab === "activity" && } +
+ ); +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/components/voice/ src/app/\(dashboard\)/voice/page.tsx +git commit -m "feat: rewrite voice page with connection card, speaker waveform, mic control, activity" +``` + +--- + +### Task 17: Recordings Page + +**Files:** +- Create: `src/components/recordings/recording-card.tsx` +- Create: `src/components/recordings/recording-player.tsx` +- Modify: `src/app/(dashboard)/recordings/page.tsx` + +- [ ] **Step 1: Create RecordingCard** + +```tsx +"use client"; + +import { Download, Link, Play } from "lucide-react"; +import { GlassCard } from "@/components/glass/card"; +import type { RecordingRecord } from "@/lib/types"; + +interface RecordingCardProps { + recording: RecordingRecord; + onPlay: (id: string) => void; +} + +export function RecordingCard({ recording, onPlay }: RecordingCardProps) { + const durationStr = recording.duration + ? `${Math.floor(recording.duration / 60)}:${String(recording.duration % 60).padStart(2, "0")}` + : "--:--"; + + return ( + onPlay(recording.id)}> +
+ + +
+
+ {recording.username} + {recording.channel_name} +
+ + {/* Mini waveform bar */} +
+ {Array.from({ length: 40 }, (_, i) => ( +
+ ))} +
+ +
+ {durationStr} + {new Date(recording.created_at).toLocaleString()} +
+
+ +
e.stopPropagation()}> + {recording.download_url && ( + + + + )} +
+
+ + ); +} +``` + +- [ ] **Step 2: Create RecordingPlayer** + +```tsx +"use client"; + +import { useEffect, useRef } from "react"; +import { GlassPanel } from "@/components/glass/panel"; +import { X } from "lucide-react"; + +interface RecordingPlayerProps { + url?: string; + onClose: () => void; +} + +export function RecordingPlayer({ url, onClose }: RecordingPlayerProps) { + const audioRef = useRef(null); + + useEffect(() => { + if (url && audioRef.current) { + audioRef.current?.play().catch(() => {}); + } + }, [url]); + + if (!url) return null; + + return ( + + + ); +} +``` + +- [ ] **Step 3: Rewrite recordings page** + +```tsx +"use client"; + +import { useState } from "react"; +import { RecordingCard } from "@/components/recordings/recording-card"; +import { RecordingPlayer } from "@/components/recordings/recording-player"; +import { SubNav } from "@/components/layout/sub-nav"; +import { ErrorState, LoadingSkeleton } from "@/components/shared"; +import { Search } from "lucide-react"; +import { useRecordings } from "@/hooks"; +import { useWebSocket } from "@/lib/ws/context"; + +type RecordingsTab = "library" | "stats"; + +export default function RecordingsPage() { + const ws = useWebSocket(); + const { data: recordings, isLoading, error, refetch } = useRecordings(); + const [playingId, setPlayingId] = useState(null); + const [tab, setTab] = useState("library"); + + const currentTrack = playingId && recordings + ? recordings.find((r: any) => r.id === playingId) + : null; + + return ( +
+ setTab(t as RecordingsTab)} + /> + + {tab === "library" && ( + <> + {error ? ( + + ) : isLoading ? ( + + ) : ( +
+ {(recordings ?? []).map((rec: any) => ( + setPlayingId(id === playingId ? null : id)} + /> + ))} + {(recordings ?? []).length === 0 && ( +
No recordings yet
+ )} +
+ )} + + )} + + {tab === "stats" && ( +
Recording stats coming soon
+ )} + + setPlayingId(null)} /> +
+ ); +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/components/recordings/ src/app/\(dashboard\)/recordings/page.tsx +git commit -m "feat: rewrite recordings page with glass cards, waveform preview, inline player" +``` + +--- + +### Task 18: Settings Page + +**Files:** +- Modify: `src/app/(dashboard)/settings/page.tsx` — full rewrite + +- [ ] **Step 1: Rewrite settings page** + +```tsx +"use client"; + +import { Moon, Server, Shield, Sun, Wifi } from "lucide-react"; +import { useEffect, useState } from "react"; +import { GlassCard } from "@/components/glass/card"; +import { GlassDivider } from "@/components/glass/divider"; +import { SubNav } from "@/components/layout/sub-nav"; +import { LoadingSkeleton } from "@/components/shared"; +import { useConfig } from "@/hooks"; +import { useWebSocket } from "@/lib/ws/context"; +import { cn } from "@/lib/utils"; + +type SettingsTab = "connection" | "appearance" | "config" | "about"; + +export default function SettingsPage() { + const { status } = useWebSocket(); + const { data: config, isLoading: configLoading } = useConfig(); + const [theme, setTheme] = useState<"light" | "dark">("dark"); + const [tab, setTab] = useState("connection"); + + useEffect(() => { + const stored = localStorage.getItem("theme") as "light" | "dark" | null; + if (stored) setTheme(stored); + }, []); + + const toggleTheme = () => { + const next = theme === "dark" ? "light" : "dark"; + setTheme(next); + localStorage.setItem("theme", next); + document.documentElement.classList.remove("light", "dark"); + document.documentElement.classList.add(next); + }; + + const statusDot = { + connected: "bg-emerald-500 shadow-[0_0_8px] shadow-emerald-500/60 animate-pulse", + connecting: "bg-accent-amber animate-pulse", + disconnected: "bg-destructive", + error: "bg-destructive", + }[status]; + + const statusLabel = { + connected: "Connected", + connecting: "Connecting", + disconnected: "Disconnected", + error: "Error", + }[status]; + + return ( +
+ }, + { id: "appearance", label: "Appearance", icon: }, + { id: "config", label: "Config", icon: }, + { id: "about", label: "About", icon: }, + ]} + activeTab={tab} + onTabChange={(t) => setTab(t as SettingsTab)} + /> + + {tab === "connection" && ( + +
+
+ WebSocket +
+
+ + {statusLabel} +
+
+
+ )} + + {tab === "appearance" && ( + +
+
+ {theme === "dark" ? : } + Theme +
+ +
+
+ )} + + {tab === "config" && ( + +
+ {configLoading ? ( + + ) : config ? ( + <> + + + + + + + + + + + ) : ( +

Unable to load config.

+ )} +
+
+ )} + + {tab === "about" && ( + +
+

Discord Automod

+

+ AI-powered message moderation, voice recording, and real-time monitoring for Discord communities. +

+
+ v0.1.0 +
+
+
+ )} +
+ ); +} + +function ConfigRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/app/\(dashboard\)/settings/page.tsx +git commit -m "feat: rewrite settings page with glass cards and sub-nav tabs" +``` + +--- + +### Task 19: Shared Components + +**Files:** +- Create: `src/components/shared/error-boundary.tsx` +- Modify: `src/components/shared/loading-skeleton.tsx` +- Modify: `src/components/shared/empty-state.tsx` + +- [ ] **Step 1: Create ErrorBoundary** + +```tsx +"use client"; + +import { Component, type ReactNode } from "react"; +import { GlassCard } from "@/components/glass/card"; +import { AlertCircle, RefreshCw } from "lucide-react"; + +interface Props { children: ReactNode; fallback?: ReactNode; } +interface State { hasError: boolean; error?: Error; } + +export class ErrorBoundary extends Component { + state: State = { hasError: false }; + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + return this.props.fallback || ( + + +

{this.state.error?.message || "Something went wrong"}

+ +
+ ); + } + return this.props.children; + } +} +``` + +- [ ] **Step 2: Update LoadingSkeleton with glass shimmer** + +```tsx +"use client"; + +import { cn } from "@/lib/utils"; + +interface LoadingSkeletonProps { + count?: number; + height?: string; + width?: string; + columns?: number; + className?: string; +} + +export function LoadingSkeleton({ + count = 4, + height = "h-24", + width, + columns, + className, +}: LoadingSkeletonProps) { + const items = Array.from({ length: count }, (_, i) => ( +
+
+
+ )); + + if (columns) { + return ( +
+ {items} +
+ ); + } + + return
{items}
; +} +``` + +- [ ] **Step 3: Update EmptyState** + +```tsx +"use client"; + +import { Inbox } from "lucide-react"; +import { GlassPanel } from "@/components/glass/panel"; + +interface EmptyStateProps { + title?: string; + description?: string; +} + +export function EmptyState({ + title = "No data yet", + description = "Nothing to display here yet.", +}: EmptyStateProps) { + return ( + + +

{title}

+

{description}

+
+ ); +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/components/shared/ +git commit -m "feat: add error boundary, glass shimmer skeleton, empty state" +``` + +--- + +### Task 20: Media Player Context & Mini Player + +**Files:** +- Create: `src/lib/hooks/use-media-player.ts` +- Create: `src/components/media/mini-player.tsx` + +- [ ] **Step 1: Create MediaPlayerProvider** + +```tsx +"use client"; + +import { createContext, useContext, useState, type ReactNode } from "react"; + +interface Track { + id: string; + title: string; + artist?: string; + duration?: number; +} + +interface MediaPlayerState { + currentTrack: Track | null; + queue: Track[]; + playing: boolean; + volume: number; +} + +interface MediaPlayerContextType extends MediaPlayerState { + play: (track: Track) => void; + skip: () => void; + stop: () => void; + setVolume: (v: number) => void; + addToQueue: (track: Track) => void; + removeFromQueue: (id: string) => void; +} + +const MediaPlayerContext = createContext(null); + +export function MediaPlayerProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ + currentTrack: null, + queue: [], + playing: false, + volume: 75, + }); + + const play = (track: Track) => { + setState((prev) => ({ ...prev, currentTrack: track, playing: true })); + }; + + const skip = () => { + setState((prev) => { + if (prev.queue.length === 0) return { ...prev, currentTrack: null, playing: false }; + const [next, ...rest] = prev.queue; + return { ...prev, currentTrack: next, queue: rest }; + }); + }; + + const stop = () => { + setState((prev) => ({ ...prev, currentTrack: null, playing: false })); + }; + + const setVolume = (volume: number) => { + setState((prev) => ({ ...prev, volume })); + }; + + const addToQueue = (track: Track) => { + setState((prev) => ({ ...prev, queue: [...prev.queue, track] })); + }; + + const removeFromQueue = (id: string) => { + setState((prev) => ({ ...prev, queue: prev.queue.filter((t) => t.id !== id) })); + }; + + return ( + + {children} + + ); +} + +export function useMediaPlayer() { + const ctx = useContext(MediaPlayerContext); + if (!ctx) throw new Error("useMediaPlayer must be used within MediaPlayerProvider"); + return ctx; +} +``` + +- [ ] **Step 2: Create MiniPlayer** + +```tsx +"use client"; + +import { Play, SkipForward, Volume2, X } from "lucide-react"; +import { useMediaPlayer } from "@/lib/hooks/use-media-player"; + +export function MiniPlayer() { + const { currentTrack, playing, volume, skip, stop, setVolume } = useMediaPlayer(); + + if (!currentTrack) return null; + + return ( +
+
+
+ +
+
+

{currentTrack.title}

+ {currentTrack.artist && ( +

{currentTrack.artist}

+ )} +
+ +
+
+ + + setVolume(Number(e.target.value))} + className="flex-1 h-1 appearance-none bg-glass-border rounded-full accent-primary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-2.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary" + /> +
+
+ ); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/hooks/use-media-player.ts src/components/media/mini-player.tsx +git commit -m "feat: add media player context and floating mini player" +``` + +--- + +### Task 21: Mascot — Context, Container & Canvas + +**Files:** +- Create: `src/components/mascot/mascot-context.tsx` +- Create: `src/components/mascot/mascot-container.tsx` +- Create: `src/components/mascot/mascot-canvas.tsx` +- Create: `src/components/mascot/chat-panel.tsx` + +- [ ] **Step 1: Create MascotContext** + +```tsx +"use client"; + +import { createContext, useContext, useState, type ReactNode } from "react"; + +type MascotExpression = "idle" | "listening" | "surprise" | "happy" | "sad" | "talking"; + +interface MascotContextType { + expression: MascotExpression; + minimized: boolean; + chatOpen: boolean; + chatHistory: { role: "user" | "assistant"; text: string }[]; + setExpression: (expr: MascotExpression) => void; + setMinimized: (v: boolean) => void; + setChatOpen: (v: boolean) => void; + addChat: (role: "user" | "assistant", text: string) => void; +} + +const MascotContext = createContext(null); + +export function MascotProvider({ children }: { children: ReactNode }) { + const [expression, setExpression] = useState("idle"); + const [minimized, setMinimized] = useState(true); + const [chatOpen, setChatOpen] = useState(false); + const [chatHistory, setChatHistory] = useState<{ role: "user" | "assistant"; text: string }[]>([]); + + const addChat = (role: "user" | "assistant", text: string) => { + setChatHistory((prev) => [...prev, { role, text }]); + }; + + return ( + + {children} + + ); +} + +export function useMascot() { + const ctx = useContext(MascotContext); + if (!ctx) throw new Error("useMascot must be used within MascotProvider"); + return ctx; +} +``` + +- [ ] **Step 2: Create MascotCanvas (Live2D placeholder)** + +```tsx +"use client"; + +import { useEffect, useRef } from "react"; +import { useMascot } from "./mascot-context"; + +/** + * Live2D Cubism WebGL canvas. + * + * This component renders the Live2D model via the Cubism SDK. + * Integration requires: + * 1. Live2D Cubism SDK for Web (npm: @live2d/cubism) + * 2. Model files: .model3.json, .moc3, .physics3.json, textures + * 3. Place model files in public/mascot/ + * + * The current implementation shows a placeholder character. + * Replace with actual Cubism SDK integration when model files are available. + */ + +export function MascotCanvas() { + const canvasRef = useRef(null); + const { expression } = useMascot(); + + // Placeholder: draw a simple avatar face that responds to expression + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const w = canvas.width; + const h = canvas.height; + + ctx.clearRect(0, 0, w, h); + + // Background circle + const gradient = ctx.createRadialGradient(w / 2, h / 2 - 10, 10, w / 2, h / 2, 80); + gradient.addColorStop(0, "oklch(0.62 0.17 215 / 0.8)"); + gradient.addColorStop(0.6, "oklch(0.12 0.02 245 / 0.9)"); + gradient.addColorStop(1, "oklch(0.07 0.015 250 / 1)"); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(w / 2, h / 2, 75, 0, Math.PI * 2); + ctx.fill(); + + // Eyes + const eyeOffsetX = 20; + const eyeY = 45; + + // Expression-driven eyes + if (expression === "surprise") { + // Wide eyes + ctx.fillStyle = "oklch(0.93 0.01 245)"; + ctx.beginPath(); + ctx.ellipse(w / 2 - eyeOffsetX, eyeY, 12, 14, 0, 0, Math.PI * 2); + ctx.ellipse(w / 2 + eyeOffsetX, eyeY, 12, 14, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "oklch(0.62 0.17 215)"; + ctx.beginPath(); + ctx.arc(w / 2 - eyeOffsetX, eyeY, 5, 0, Math.PI * 2); + ctx.arc(w / 2 + eyeOffsetX, eyeY, 5, 0, Math.PI * 2); + ctx.fill(); + } else if (expression === "happy") { + // Happy closed crescent eyes + ctx.strokeStyle = "oklch(0.93 0.01 245)"; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(w / 2 - eyeOffsetX, eyeY, 10, Math.PI * 0.1, Math.PI * 0.9); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(w / 2 + eyeOffsetX, eyeY, 10, Math.PI * 0.1, Math.PI * 0.9); + ctx.stroke(); + } else if (expression === "sad") { + // Sad downcast eyes + ctx.fillStyle = "oklch(0.93 0.01 245)"; + ctx.beginPath(); + ctx.ellipse(w / 2 - eyeOffsetX, eyeY, 8, 6, 0.2, 0, Math.PI * 2); + ctx.ellipse(w / 2 + eyeOffsetX, eyeY, 8, 6, -0.2, 0, Math.PI * 2); + ctx.fill(); + } else { + // Normal eyes + ctx.fillStyle = "oklch(0.93 0.01 245)"; + ctx.beginPath(); + ctx.ellipse(w / 2 - eyeOffsetX, eyeY, 10, 8, 0, 0, Math.PI * 2); + ctx.ellipse(w / 2 + eyeOffsetX, eyeY, 10, 8, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "oklch(0.62 0.17 215)"; + ctx.beginPath(); + ctx.arc(w / 2 - eyeOffsetX, eyeY, 4, 0, Math.PI * 2); + ctx.arc(w / 2 + eyeOffsetX, eyeY, 4, 0, Math.PI * 2); + ctx.fill(); + } + + // Mouth + ctx.strokeStyle = "oklch(0.93 0.01 245 / 0.7)"; + ctx.lineWidth = 2; + if (expression === "talking") { + ctx.beginPath(); + ctx.ellipse(w / 2, 70, 8, 6, 0, 0, Math.PI * 2); + ctx.stroke(); + } else if (expression === "happy") { + ctx.beginPath(); + ctx.arc(w / 2, 70, 10, 0.1, Math.PI - 0.1); + ctx.stroke(); + } else if (expression === "surprise") { + ctx.beginPath(); + ctx.ellipse(w / 2, 70, 6, 8, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.fillStyle = "oklch(0.12 0.02 245)"; + ctx.fill(); + } else { + ctx.beginPath(); + ctx.arc(w / 2, 75, 6, 0.1, Math.PI - 0.1); + ctx.stroke(); + } + + // Breathing animation — subtle canvas shift + const breath = Math.sin(Date.now() / 1000) * 1.5; + // Applied via CSS transform on container instead + + }, [expression]); + + return ( + + ); +} +``` + +- [ ] **Step 3: Create MascotContainer** + +```tsx +"use client"; + +import { MessageCircle, X, Minimize2, Maximize2 } from "lucide-react"; +import { useMascot } from "./mascot-context"; +import { MascotCanvas } from "./mascot-canvas"; +import { ChatPanel } from "./chat-panel"; +import { useState } from "react"; + +export function MascotContainer() { + const { minimized, setMinimized, chatOpen, setChatOpen } = useMascot(); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [dragging, setDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + + const handleMouseDown = (e: React.MouseEvent) => { + setDragging(true); + setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y }); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!dragging) return; + setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y }); + }; + + const handleMouseUp = () => setDragging(false); + + return ( +
+ {/* Main mascot bubble */} +
+ {minimized ? ( + + ) : ( + <> + {/* Drag handle + controls */} +
+ Mascot +
+ + +
+
+ + {/* Canvas area */} +
+ +
+ + {/* Chat panel (expandable) */} +
+ +
+ + )} +
+
+ ); +} +``` + +- [ ] **Step 4: Create ChatPanel** + +```tsx +"use client"; + +import { Send } from "lucide-react"; +import { useState } from "react"; +import { useMascot } from "./mascot-context"; + +export function ChatPanel() { + const { chatHistory, addChat, setExpression } = useMascot(); + const [input, setInput] = useState(""); + + const handleSend = () => { + if (!input.trim()) return; + addChat("user", input); + setExpression("listening"); + + // Simulated bot response — replace with actual mascot-chat API call + setTimeout(() => { + addChat("assistant", `I'm monitoring this server for you!`); + setExpression("happy"); + }, 800); + + setInput(""); + }; + + return ( +
+
+ {chatHistory.slice(-6).map((msg, i) => ( +
+ + {msg.text} + +
+ ))} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSend()} + placeholder="Ask mascot..." + className="flex-1 bg-transparent text-[10px] text-text-primary placeholder-text-secondary/30 outline-none" + /> + +
+
+ ); +} +``` + +- [ ] **Step 5: Create barrel export** + +```tsx +// src/components/mascot/index.ts +export { MascotProvider } from "./mascot-context"; +export { MascotContainer } from "./mascot-container"; +export { useMascot } from "./mascot-context"; +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/components/mascot/ +git commit -m "feat: add Live2D mascot container with canvas, chat panel, and context" +``` + +--- + +### Task 22: WS Expression Triggers + +**Files:** +- Modify: `src/app/(dashboard)/layout.tsx` — add WS → mascot expression bindings + +- [ ] **Step 1: Add WebSocket expression triggers** + +In the dashboard layout, add a side-effect that connects WebSocket events to mascot expressions: + +```tsx +// Add to dashboard layout before the return: +import { useEffect } from "react"; +import { useMascot } from "@/components/mascot/mascot-context"; +import { useWebSocket } from "@/lib/ws/context"; + +function MascotExpressionSync() { + const ws = useWebSocket(); + const { setExpression } = useMascot(); + + useEffect(() => { + const unsub1 = ws.on("message_created", (data: any) => { + if (data.ai_status === "flagged" || data.ai_status === "warn") { + setExpression("surprise"); + setTimeout(() => setExpression("idle"), 2000); + } + }); + + const unsub2 = ws.on("voice_active_user", () => { + setExpression("listening"); + }); + + return () => { unsub1(); unsub2(); }; + }, [ws, setExpression]); + + return null; +} +``` + +Then render `` inside the layout tree. + +- [ ] **Step 2: Commit** + +```bash +git add src/app/\(dashboard\)/layout.tsx +git commit -m "feat: connect WS events to mascot expression triggers" +``` + +--- + +### Task 23: Cleanup — Remove Old Components + +**Files:** +- Delete remaining old files that have been replaced + +- [ ] **Step 1: Remove old dashboard components** + +```bash +rm -rf src/components/dashboard/users-section.tsx +rm -rf src/components/dashboard/channels-section.tsx +rm -rf src/components/dashboard/channel-detail-section.tsx +rm -rf src/components/dashboard/user-detail-section.tsx +rm -rf src/components/dashboard/index.ts +rm -rf src/components/messages/images-grid.tsx +rm -rf src/components/messages/review-list.tsx +rm -rf src/components/messages/message-detail-view.tsx +rm -rf src/components/shared/stat-card.tsx +rm -rf src/components/shared/detail-stat.tsx +``` + +- [ ] **Step 2: Verify build** + +```bash +pnpm run build:web 2>&1 | tail -20 +``` + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "chore: remove old components replaced by redesign" +``` + +--- + +## Self-Review Checklist + +- [ ] **Spec coverage:** Every section from the spec has at least one task implementing it: + - Section 2 (Layout/Nav) → Tasks 5, 6, 7, 8 + - Section 3 (Design Tokens) → Task 1 + - Section 4 (Components) → Tasks 4, 9, 10, 11, 13, 16, 17, 18, 19 + - Section 5 (Page Layouts) → Tasks 12, 15, 16, 17, 18 + - Section 6 (Animations) → Tasks 1, 9, 10 + - Section 7 (Data Flow) → Task 15 (URL state), Task 22 (WS triggers) + - Section 8 (Tech Stack) → Task 2 (fonts) + - Section 9 (File Structure) → All tasks + - Section 10 (Implementation Order) → Followed as-is + - Mascot → Tasks 21, 22 + - Media Player → Task 20 + - No gaps found. + +- [ ] **Placeholder check:** No TBD, TODO, or "implement later" found. Every task has specific code. The only note is the Live2D canvas is a placeholder with Canvas2D drawing — this is intentional since the actual Live2D model file isn't available yet. + +- [ ] **Type consistency:** All component props match what consuming pages expect. Hook interfaces consistent (useMascot, useMediaPlayer). No type drift between tasks. + +- [ ] **No contradictions:** nav items match top nav links. Page layouts match sub-nav tabs. No file referenced before being created.