Files
GMW/docs/superpowers/plans/2026-07-27-frontend-refactor-plan.md
T
DeveloperandClaude Opus 4.8 5802d02e29
Build & Deploy / build-and-push (discord-gateway) (push) Failing after 2m22s
Build & Deploy / build-and-push (backend) (push) Failing after 3m22s
Build & Deploy / build-and-push (proxy) (push) Successful in 1m36s
Build & Deploy / deploy (push) Skipped
refactor: large codebase cleanup - consolidate schemas, migrate to Drizzle ORM, extract frontend components, modernize Docker builds
- Consolidate all DB schema definitions into packages/shared as single source of truth
- Migrate backend from raw SQL to Drizzle ORM across all modules
- Extract frontend inline UI into separate component files
- Refactor discord-gateway circuitBreaker into conversationState + moderationState
- Convert messageStore to Proxy singleton pattern
- Add validateBody/validateQuery middleware + Zod schemas for API endpoints
- Modernize Docker builds with multi-stage + pnpm deploy
- Migrate CI/CD from deployment to image-based pipeline
- Remove 60+ unused/dead files (~15K lines)
- Update color scheme from sky-blue to teal-cyan
- Move DB connection management to @bete/shared/database

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 21:54:31 +07:00

1347 lines
46 KiB
Markdown

# Frontend Refactor 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:** Refactor frontend for cleaner structure, API alignment, data-fetching consistency, rebranding, and dead code removal.
**Architecture:** Extract inline page components into `components/<feature>/` directories; split `voiceApi` into `voiceApi` + `mediaApi`; consolidate shared types; convert manual state hooks to React Query; rebrand names.
**Tech Stack:** React 19, Next.js 16 (App Router), TypeScript, TanStack Query, Tailwind v4
## Global Constraints
- Every page in `app/(dashboard)/` remains a `"use client"` `default export` function
- No changes to backend API endpoints or their paths
- No functional changes — visual output must be identical
- Import paths use `@/` alias throughout
- All existing exports from `hooks/index.ts` must remain (consumers may import from there)
- Rename "bete"/"GMW" → "Discord Automod" in user-visible text only
---
## File Map
### Infrastructure Changes
| Action | File | Purpose |
|--------|------|---------|
| Create | `src/lib/ws-hook.ts` | Shared `WsHook` type consumed by 3 hook files |
| Modify | `src/hooks/use-messages.ts` | Import `WsHook` from shared |
| Modify | `src/hooks/use-media.ts` | Import `WsHook` from shared, fix import order |
| Modify | `src/hooks/use-recordings.ts` | Import `WsHook` from shared |
| Modify | `src/lib/navigation.ts` | Fix Settings icon (BarChart3 → Settings) |
| Modify | `src/components/shared/loading-skeleton.tsx` | Fix Tailwind v4 dynamic class |
| Modify | `src/components/layout/app-sidebar.tsx` | Consolidate `isActive` into shared utility |
| Modify | `src/components/layout/app-header.tsx` | Consolidate `isActive` into shared utility |
| Modify | `src/components/layout/mobile-nav.tsx` | Consolidate `isActive` into shared utility |
### API Separation
| Action | File | Purpose |
|--------|------|---------|
| Create | `src/lib/api/media.ts` | Media player API (moved from voiceApi) |
| Modify | `src/lib/api/voice.ts` | Remove media methods, keep voice-only |
| Modify | `src/lib/api/index.ts` | Add `mediaApi` export |
| Modify | `src/hooks/use-media.ts` | Import from `mediaApi` instead of `voiceApi` |
### Dead Code Removal
| Action | File | Purpose |
|--------|------|---------|
| Delete | `src/components/landing/live-stats.tsx` | Unused — landing page redirects |
| Delete | `src/components/ui/item.tsx` | Unused component |
| Modify | `src/hooks/use-messages.ts` | Remove `useSearch` export |
| Modify | `src/hooks/index.ts` | Remove `useSearch` from re-exports |
| Modify | `src/lib/format.ts` | Remove duplicate `extractImage` (if present) |
### Feature Extraction: Messages
| Action | File | Purpose |
|--------|------|---------|
| Create | `src/components/messages/message-card.tsx` | Extracted from messages/page.tsx |
| Create | `src/components/messages/ai-status-badge.tsx` | Extracted from messages/page.tsx |
| Create | `src/components/messages/message-detail-view.tsx` | Extracted: DetailView + MiniStat |
| Create | `src/components/messages/images-grid.tsx` | Images tab content (from messages/page.tsx) |
| Create | `src/components/messages/review-list.tsx` | Review tab content (from messages/page.tsx) |
| Modify | `src/app/(dashboard)/messages/page.tsx` | Use extracted components |
### Feature Extraction: Dashboard
| Action | File | Purpose |
|--------|------|---------|
| Create | `src/components/dashboard/stats-section.tsx` | Extracted from dashboard/page.tsx StatsSection |
| Create | `src/components/dashboard/users-section.tsx` | Extracted from dashboard/page.tsx UsersSection |
| Create | `src/components/dashboard/user-detail-section.tsx` | Extracted from dashboard/page.tsx |
| Create | `src/components/dashboard/channels-section.tsx` | Extracted from dashboard/page.tsx |
| Create | `src/components/dashboard/channel-detail-section.tsx` | Extracted from dashboard/page.tsx |
| Modify | `src/app/(dashboard)/dashboard/page.tsx` | Use extracted components |
### Feature Extraction: Other Pages
| Action | File | Purpose |
|--------|------|---------|
| Create | `src/components/voice/voice-connection-card.tsx` | Voice connection UI |
| Create | `src/components/voice/active-speakers-panel.tsx` | Active speakers list |
| Create | `src/components/voice/microphone-card.tsx` | Mic toggle UI |
| Modify | `src/app/(dashboard)/voice/page.tsx` | Use extracted components |
| Create | `src/components/media/music-player.tsx` | Media player UI |
| Modify | `src/app/(dashboard)/media/page.tsx` | Use extracted components |
| Create | `src/components/recordings/recording-list.tsx` | Recording list UI |
| Modify | `src/app/(dashboard)/recordings/page.tsx` | Use extracted components |
| Create | `src/components/analysis/search-panel.tsx` | Analysis search UI |
| Modify | `src/app/(dashboard)/analysis/page.tsx` | Use extracted components |
### Data Fetching Consistency
| Modify | `src/components/shared/guild-selector.tsx` | Use `useGuilds` + `useConfig` instead of manual fetch |
| Modify | `src/hooks/use-voice.ts` | `useVoiceChannels` → React Query |
| Modify | `src/components/chatbot/chatbot.tsx` | Use React Query for history + mutate for send |
### Rebranding
| Modify | `src/app/layout.tsx` | Title: "Discord Automod — Moderation Dashboard" |
| Modify | `src/app/(dashboard)/settings/page.tsx` | Text references |
| Modify | Various comments/files | "bete" → "Discord Automod", "GMW" → "Discord Automod" |
### Unused shadcn Cleanup
| Delete | `src/components/ui/*.tsx` | Components verified unused by grep |
---
## Tasks
### Task 1: Shared Infrastructure
**Files:**
- Create: `src/lib/ws-hook.ts`
- Modify: `src/hooks/use-messages.ts` (import WsHook)
- Modify: `src/hooks/use-media.ts` (import WsHook, fix import placement)
- Modify: `src/hooks/use-recordings.ts` (import WsHook)
- Modify: `src/lib/navigation.ts` (Settings icon)
- Modify: `src/components/shared/loading-skeleton.tsx` (fix grid)
- Modify: `src/components/layout/app-sidebar.tsx` (isActive → shared or inline)
- Modify: `src/components/layout/app-header.tsx` (same)
- Modify: `src/components/layout/mobile-nav.tsx` (same)
**Interfaces:**
- Produces: `WsHook` type in `lib/ws-hook.ts` — exact same shape as current duplicate
- Produces: `isActivePath(pathname: string, matchPrefix: string): boolean` — shared utility in lib
- [ ] **Step 1: Create `src/lib/ws-hook.ts`**
```typescript
import type { WsEventType } from "./ws/types";
export type WsHook = {
on: <E extends WsEventType>(
eventType: E,
handler: (data: unknown) => void,
) => () => void;
};
```
- [ ] **Step 2: Update `src/hooks/use-messages.ts`**
Replace the local `WsHook` type definition with:
```typescript
import type { WsHook } from "@/lib/ws-hook";
```
And remove the local `type WsHook = ...` block (lines ~8-13).
- [ ] **Step 3: Update `src/hooks/use-media.ts`**
Same import replacement. Also move the `import { useEffect }` from line 61 to the top import block with the other react imports.
- [ ] **Step 4: Update `src/hooks/use-recordings.ts`**
Same import replacement.
- [ ] **Step 5: Fix navigation icon for Settings**
In `src/lib/navigation.ts`, change:
```typescript
import { ..., Settings, ... } from "lucide-react";
```
Replace `BarChart3` with `Settings` for the settings nav item.
- [ ] **Step 6: Fix `LoadingSkeleton` grid**
In `src/components/shared/loading-skeleton.tsx`, replace:
```tsx
columns > 1 ? `grid-cols-1 md:grid-cols-${columns}` : "grid-cols-1",
```
With:
```tsx
columns > 1 ? "grid-cols-1 md:grid-cols-2" as const : "grid-cols-1",
```
(Tailwind v4 doesn't support dynamic class construction. Max columns the app uses is 2, so hardcode md:grid-cols-2.)
- [ ] **Step 7: Create shared `isActivePath` utility**
In `src/lib/navigation.ts`, add:
```typescript
export function isActivePath(pathname: string, matchPrefix: string): boolean {
if (matchPrefix === "/dashboard") return pathname === "/dashboard";
return pathname.startsWith(matchPrefix);
}
```
In `lib/utils.ts` or keep in `lib/navigation.ts` — I'll put it in `navigation.ts` since it's navigation-related.
- [ ] **Step 8: Update layout files to use shared `isActivePath`**
In `app-sidebar.tsx`, replace inline `isActive` with `import { isActivePath } from "@/lib/navigation"`.
In `app-header.tsx`, same.
In `mobile-nav.tsx`, same.
- [ ] **Step 9: Verify the app compiles**
Run: `cd /home/code/GMW/services/frontend && npx tsc --noEmit`
Expected: No type errors (or only pre-existing ones unrelated to these changes).
- [ ] **Step 10: Commit**
```bash
git add src/lib/ws-hook.ts src/hooks/use-messages.ts src/hooks/use-media.ts src/hooks/use-recordings.ts src/lib/navigation.ts src/components/shared/loading-skeleton.tsx src/components/layout/app-sidebar.tsx src/components/layout/app-header.tsx src/components/layout/mobile-nav.tsx
git commit -m "refactor(frontend): shared WsHook type, fix icon/grid, consolidate isActive"
```
---
### Task 2: API Layer Separation (voiceApi / mediaApi)
**Files:**
- Create: `src/lib/api/media.ts`
- Modify: `src/lib/api/voice.ts`
- Modify: `src/lib/api/index.ts`
- Modify: `src/hooks/use-media.ts`
**Interfaces:**
- Consumes: existing voiceApi shape
- Produces: `mediaApi` export with `getStatus`, `queue`, `skip`, `stop`, `volume`
- [ ] **Step 1: Create `src/lib/api/media.ts`**
```typescript
import type { MediaState } from "@/lib/types";
import { api } from "./client";
export const mediaApi = {
getStatus: () => api.get<MediaState>("/api/media/status"),
queue: (source: string, mode: string) =>
api.post<MediaState>("/api/media/queue", { source, mode }),
skip: () => api.post<MediaState>("/api/media/skip", {}),
stop: () => api.post<MediaState>("/api/media/stop", {}),
volume: (volume: number) =>
api.post<MediaState>("/api/media/volume", { volume }),
};
```
- [ ] **Step 2: Remove media methods from `src/lib/api/voice.ts`**
Delete `getMediaStatus`, `mediaQueue`, `mediaSkip`, `mediaStop`, `mediaVolume`.
Remove `MediaState` from the import (keep `Channel`, `Guild`, `VoiceStatus`).
```typescript
import type { Channel, Guild, VoiceStatus } from "@/lib/types";
import { api } from "./client";
export const voiceApi = {
getGuilds: () => api.get<Guild[]>("/api/guilds"),
getTextChannels: (guildId: string) =>
api.get<Channel[]>(`/api/guilds/${guildId}/channels`),
getVoiceChannels: (guildId: string) =>
api.get<Channel[]>(`/api/guilds/${guildId}/voice-channels`),
getStatus: () => api.get<VoiceStatus>("/api/voice/status"),
connect: (guildId: string, channelId: string) =>
api.post<VoiceStatus>("/api/voice/connect", { guildId, channelId }),
disconnect: () => api.post<VoiceStatus>("/api/voice/disconnect", {}),
sendCommand: (command: string) =>
api.post<{ success: boolean; command: string }>("/api/voice/command", {
command,
}),
};
```
- [ ] **Step 3: Update `src/lib/api/index.ts`**
```typescript
export { chatbotApi } from "./chatbot";
export { ApiError, api, apiRequest } from "./client";
export { configApi } from "./config";
export { dashboardApi } from "./dashboard";
export { mediaApi } from "./media";
export { messagesApi } from "./messages";
export { recordingsApi } from "./recordings";
export { uiStateApi } from "./ui-state";
export { voiceApi } from "./voice";
```
- [ ] **Step 4: Update `src/hooks/use-media.ts`**
Change the import from `voiceApi` to `mediaApi`:
```typescript
import { mediaApi } from "@/lib/api";
```
Replace all `voiceApi.getMediaStatus()``mediaApi.getStatus()`, `voiceApi.mediaQueue(...)``mediaApi.queue(...)`, etc.
- [ ] **Step 5: Typecheck**
Run: `cd /home/code/GMW/services/frontend && npx tsc --noEmit`
- [ ] **Step 6: Commit**
```bash
git add src/lib/api/media.ts src/lib/api/voice.ts src/lib/api/index.ts src/hooks/use-media.ts
git commit -m "refactor(frontend): split mediaApi from voiceApi"
```
---
### Task 3: Dead Code Removal
**Files:**
- Delete: `src/components/landing/live-stats.tsx`
- Delete: `src/components/ui/item.tsx`
- Delete: `src/components/landing/` (if empty after)
- Modify: `src/hooks/use-messages.ts` (remove `useSearch`)
- Modify: `src/hooks/index.ts` (remove `useSearch` export)
- [ ] **Step 1: Delete `src/components/landing/live-stats.tsx`**
```bash
rm src/components/landing/live-stats.tsx
```
- [ ] **Step 2: Delete `src/components/ui/item.tsx`**
```bash
rm src/components/ui/item.tsx
```
- [ ] **Step 3: Remove `useSearch` from `use-messages.ts`**
Delete the entire `useSearch` function (lines ~103-109).
- [ ] **Step 4: Update `hooks/index.ts`**
Remove `useSearch` from the re-export line:
```typescript
export {
useImages,
useLoadMore,
useMessageDetail,
useMessages,
useMessagesHasMore,
useMessagesWsSync,
useReanalyze,
useReanalyzeBatch,
useReview,
useTextChannels,
} from "./use-messages";
```
- [ ] **Step 5: Remove `components/landing/` directory if empty**
```bash
rmdir src/components/landing/ 2>/dev/null || true
```
- [ ] **Step 6: Typecheck & commit**
```bash
cd /home/code/GMW/services/frontend && npx tsc --noEmit
git add src/components/landing/ src/components/ui/item.tsx src/hooks/use-messages.ts src/hooks/index.ts
git commit -m "refactor(frontend): remove dead code (live-stats, item, useSearch)"
```
---
### Task 4: Messages Feature Components
**Files:**
- Create: `src/components/messages/ai-status-badge.tsx`
- Create: `src/components/messages/message-card.tsx`
- Create: `src/components/messages/message-detail-view.tsx`
- Create: `src/components/messages/images-grid.tsx`
- Create: `src/components/messages/review-list.tsx`
- Modify: `src/app/(dashboard)/messages/page.tsx`
**Interfaces:**
- Consumes: `MessageRecord`, `AttachmentRecord` types from `@/lib/types`
- Produces: Exported components consumed by `messages/page.tsx`
- [ ] **Step 1: Create `src/components/messages/ai-status-badge.tsx`**
Extract the `AiStatusBadge` function from `messages/page.tsx`:
```typescript
"use client";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
const STATUS_STYLES: Record<string, string> = {
clean: "bg-green-500/15 text-green-600 dark:text-green-400 border-green-500/20",
warn: "bg-yellow-500/15 text-yellow-600 dark:text-yellow-400 border-yellow-500/20",
flagged: "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20",
error: "bg-gray-500/15 text-gray-600 dark:text-gray-400 border-gray-500/20",
pending: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20",
processing: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20",
};
export function AiStatusBadge({ status }: { status?: string | null }) {
const style = STATUS_STYLES[status ?? ""];
if (!style) return null;
return (
<Badge
variant="outline"
className={cn("text-[10px] px-1.5 py-0 h-4 font-medium", style)}
>
{status}
</Badge>
);
}
```
- [ ] **Step 2: Create `src/components/messages/message-card.tsx`**
Extract `MessageCard` + `extractFirstImage` helper:
```typescript
"use client";
import { Hash, Progress, RefreshCw } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { AiStatusBadge } from "./ai-status-badge";
import { safeParseJsonArray } from "@/lib/format";
import type { MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
function extractFirstImage(metadata: string | null | undefined): string | null {
if (!metadata) return null;
try {
const m = JSON.parse(metadata);
const atts: Array<{ url: string; contentType?: string }> = m.attachments ?? [];
return atts.find((a) => a.contentType?.startsWith("image/"))?.url ?? null;
} catch {
return null;
}
}
const SEVERITY_BORDERS: Record<string, string> = {
low: "border-l-sky-400",
medium: "border-l-yellow-400",
high: "border-l-orange-400",
critical: "border-l-red-500",
};
export function MessageCard({
message: msg,
onClick,
onReanalyze,
}: {
message: MessageRecord;
onClick: (id: string) => void;
onReanalyze: (id: string) => void;
}) {
const severity = SEVERITY_BORDERS[msg.ai_severity ?? ""];
return (
<Card
className={cn(
"cursor-pointer transition-all duration-200 hover:bg-accent/5 hover:shadow-sm",
severity && "border-l-2",
severity,
)}
onClick={() => onClick(msg.id)}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Avatar className="size-8 shrink-0 mt-0.5">
<AvatarImage src={msg.avatar_url ?? undefined} />
<AvatarFallback className="text-xs">
{msg.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{msg.username}</span>
<span className="text-xs text-muted-foreground">
{new Date(msg.created_at).toLocaleString()}
</span>
<span className="text-xs text-muted-foreground">
<Hash className="size-3 inline mr-0.5" />
{msg.channel_id.slice(0, 8)}
</span>
<AiStatusBadge status={msg.ai_status} />
{msg.ai_severity && msg.ai_severity !== "none" && (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4">
{msg.ai_severity}
</Badge>
)}
{msg.type === "deleted" && (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4">
deleted
</Badge>
)}
{msg.type === "edited" && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4">
edited
</Badge>
)}
</div>
<p
className={cn(
"text-sm leading-relaxed",
msg.type === "deleted" && "italic text-muted-foreground line-through",
)}
>
{msg.content}
</p>
{(() => {
const u = extractFirstImage(msg.metadata);
if (!u) return null;
return (
<img
src={u}
alt=""
className="mt-2 max-h-48 rounded-lg border border-border/50 object-cover"
/>
);
})()}
{msg.ai_moderation_flags && msg.ai_moderation_flags !== "[]" && (
<div className="flex flex-wrap gap-1">
{safeParseJsonArray(msg.ai_moderation_flags).map((f) => (
<Badge key={f} variant="destructive" className="text-[10px] px-1.5 py-0 h-4">
{f}
</Badge>
))}
</div>
)}
{msg.ai_analysis && (
<p className="text-xs text-muted-foreground italic line-clamp-2 leading-relaxed">
{msg.ai_analysis}
</p>
)}
{msg.ai_confidence != null && (
<div className="flex items-center gap-2 max-w-40">
<Progress value={msg.ai_confidence * 100} className="h-1.5" />
<span className="text-[11px] text-muted-foreground tabular-nums shrink-0">
{(msg.ai_confidence * 100).toFixed(0)}%
</span>
</div>
)}
<Button
variant="ghost"
size="xs"
onClick={(e) => {
e.stopPropagation();
onReanalyze(msg.id);
}}
>
<RefreshCw className="size-3 mr-1" /> Reanalyze
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
```
- [ ] **Step 3: Create `src/components/messages/message-detail-view.tsx`**
Extract `DetailView` + `MiniStat`:
```typescript
"use client";
import { ExternalLink, Sparkles } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { formatBytes, safeParseJsonArray } from "@/lib/format";
import type { MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
interface DetailAttachment {
id: string;
filename: string;
type: string;
size: number;
uploaded_url?: string | null;
discord_url?: string | null;
}
function MiniStat({
label,
value,
destructive,
capitalize,
}: {
label: string;
value: string;
destructive?: boolean;
capitalize?: boolean;
}) {
return (
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p
className={cn(
"text-sm font-medium mt-0.5",
capitalize && "capitalize",
destructive && "text-destructive",
)}
>
{value}
</p>
</CardContent>
</Card>
);
}
export function MessageDetailView({
message,
attachments,
}: {
message: MessageRecord;
attachments: DetailAttachment[];
}) {
return (
<div className="space-y-5">
<div className="flex items-start gap-3">
<Avatar className="size-10">
<AvatarImage src={message.avatar_url ?? undefined} />
<AvatarFallback>
{message.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{message.username}</span>
<span className="text-xs text-muted-foreground">
{new Date(message.created_at).toLocaleString()}
</span>
{message.type === "deleted" && (
<Badge variant="destructive" className="text-[10px]">deleted</Badge>
)}
{message.type === "edited" && (
<Badge variant="outline" className="text-[10px]">edited</Badge>
)}
</div>
<p className="text-sm mt-2 whitespace-pre-wrap break-words leading-relaxed">
{message.content}
</p>
</div>
</div>
{message.ai_analysis && (
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="size-4 text-primary" />
<p className="text-xs text-muted-foreground font-medium">AI Analysis</p>
</div>
<p className="text-sm leading-relaxed">{message.ai_analysis}</p>
</div>
)}
{message.ai_moderation_flags && message.ai_moderation_flags !== "[]" && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground font-medium">Moderation Flags</p>
<div className="flex flex-wrap gap-1.5">
{safeParseJsonArray(message.ai_moderation_flags).map((f) => (
<Badge key={f} variant="destructive" className="text-[11px]">{f}</Badge>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{message.ai_status && (
<MiniStat label="Status" value={message.ai_status} capitalize />
)}
{message.ai_severity && message.ai_severity !== "none" && (
<MiniStat label="Severity" value={message.ai_severity} destructive capitalize />
)}
{message.ai_confidence != null && (
<MiniStat label="Confidence" value={`${(message.ai_confidence * 100).toFixed(0)}%`} />
)}
{message.ai_recommended_action && message.ai_recommended_action !== "none" && (
<MiniStat label="Action" value={message.ai_recommended_action} capitalize />
)}
</div>
{attachments.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground font-medium">
Attachments ({attachments.length})
</p>
<div className="grid grid-cols-2 gap-2">
{attachments.map((a) => (
<a
key={a.id}
href={a.uploaded_url ?? a.discord_url ?? "#"}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-lg border border-border/50 p-2 hover:bg-muted transition-colors group"
>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{a.filename}</p>
<p className="text-[11px] text-muted-foreground">
{a.type} · {formatBytes(a.size)}
</p>
</div>
<ExternalLink className="size-3 shrink-0 text-muted-foreground/50 group-hover:text-muted-foreground transition-colors" />
</a>
))}
</div>
</div>
)}
</div>
);
}
```
- [ ] **Step 4: Create `src/components/messages/images-grid.tsx`**
```typescript
"use client";
import { ImageIcon } from "lucide-react";
import { Card } from "@/components/ui/card";
import type { MessageRecord } from "@/lib/types";
function extractImage(metadata: string | null | undefined): string | null {
if (!metadata) return null;
try {
const m = JSON.parse(metadata);
const atts: Array<{ url: string; contentType?: string }> = m.attachments ?? [];
return atts.find((a) => a.contentType?.startsWith("image/"))?.url ?? null;
} catch {
return null;
}
}
export function ImagesGrid({
images,
onClick,
}: {
images: MessageRecord[];
onClick: (id: string) => void;
}) {
if (images.length === 0) {
return (
<div className="col-span-full flex flex-col items-center justify-center py-20 text-center">
<ImageIcon className="size-10 text-muted-foreground/40 mb-3" aria-label="No images" />
<p className="text-sm text-muted-foreground">No images yet.</p>
</div>
);
}
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 animate-fade-in-up">
{images.map((msg) => {
const imgUrl = extractImage(msg.metadata);
return (
<Card
key={msg.id}
className="group relative overflow-hidden cursor-pointer"
onClick={() => onClick(msg.id)}
>
<div className="aspect-square relative bg-muted">
{imgUrl ? (
<img
src={imgUrl}
alt={msg.content || "Image"}
className="absolute inset-0 size-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="flex items-center justify-center size-full text-muted-foreground text-xs">
No image
</div>
)}
{msg.content && (
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-end p-3">
<p className="text-xs text-white/90 line-clamp-2">
{msg.username}: {msg.content}
</p>
</div>
)}
</div>
</Card>
);
})}
</div>
);
}
```
- [ ] **Step 5: Create `src/components/messages/review-list.tsx`**
```typescript
"use client";
import { Flag } from "lucide-react";
import { MessageCard } from "./message-card";
import type { MessageRecord } from "@/lib/types";
export function ReviewList({
reviews,
onClick,
onReanalyze,
}: {
reviews: MessageRecord[];
onClick: (id: string) => void;
onReanalyze: (id: string) => void;
}) {
if (reviews.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Flag className="size-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">No flagged messages to review.</p>
</div>
);
}
return (
<div className="space-y-2 animate-fade-in-up">
{reviews.map((msg) => (
<MessageCard
key={msg.id}
message={msg}
onClick={onClick}
onReanalyze={onReanalyze}
/>
))}
</div>
);
}
```
- [ ] **Step 6: Simplify `src/app/(dashboard)/messages/page.tsx`**
Replace the entire file with a thin composition layer:
```typescript
"use client";
import { useCallback, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Flag, Loader2, RefreshCw, Search } from "lucide-react";
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared";
import { GuildSelector } from "@/components/shared/guild-selector";
import { ImagesGrid } from "@/components/messages/images-grid";
import { MessageCard } from "@/components/messages/message-card";
import { MessageDetailView } from "@/components/messages/message-detail-view";
import { ReviewList } from "@/components/messages/review-list";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
useImages,
useLoadMore,
useMessageDetail,
useMessages,
useMessagesHasMore,
useMessagesWsSync,
useReanalyze,
useReanalyzeBatch,
useReview,
useTextChannels,
} from "@/hooks";
import { messagesApi } from "@/lib/api";
import { useWebSocket } from "@/lib/ws/context";
export default function MessagesPage() {
const [guildId, setGuildId] = useState("");
const [selectedChannel, setSelectedChannel] = useState("");
const [viewTab, setViewTab] = useState<"all" | "images" | "review">("all");
const [searchQuery, setSearchQuery] = useState("");
const [detailId, setDetailId] = useState<string | null>(null);
const ws = useWebSocket();
const { data: channels = [] } = useTextChannels(guildId);
const { data: messages, isLoading, error, refetch } = useMessages(guildId, selectedChannel || undefined);
const { data: cursorData, refetch: refetchCursor } = useMessagesHasMore(guildId, selectedChannel || undefined);
const loadMoreMut = useLoadMore();
const { data: images } = useImages(guildId);
const { data: reviews } = useReview(selectedChannel || undefined);
const reanalyzeMut = useReanalyze();
const reanalyzeBatchMut = useReanalyzeBatch();
useMessagesWsSync(ws, guildId);
const { message: detailMessage, attachments: detailAttachments, loading: detailLoading } = useMessageDetail(detailId);
const [searchEnabled, setSearchEnabled] = useState(false);
const { data: searchResults, isFetching: searching } = useQuery({
queryKey: ["messages-search", guildId, searchQuery],
queryFn: async () => {
const result = await messagesApi.search(searchQuery, 50);
return result.results;
},
enabled: searchEnabled && !!searchQuery && !!guildId,
});
const handleSearch = useCallback(() => {
if (!searchQuery.trim()) return;
setSearchEnabled(true);
}, [searchQuery]);
const handleLoadMore = useCallback(() => {
if (!cursorData?.cursor || loadMoreMut.isPending) return;
loadMoreMut.mutate({
guildId,
channelId: selectedChannel || undefined,
cursor: cursorData.cursor,
});
}, [cursorData, loadMoreMut, guildId, selectedChannel]);
const displayMessages = searchResults ?? messages ?? [];
const hasMore = cursorData?.hasMore ?? false;
const isEmpty = !isLoading && displayMessages.length === 0;
if (error) {
return (
<div className="space-y-5">
<GuildSelector value={guildId} onChange={setGuildId} />
<ErrorState message={error.message} onRetry={refetch} />
</div>
);
}
return (
<div className="space-y-5">
<GuildSelector value={guildId} onChange={setGuildId} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search messages..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
className="pl-9 h-9"
/>
</div>
{channels.length > 0 && (
<Select
value={selectedChannel}
onValueChange={(v) => v && setSelectedChannel(v)}
>
<SelectTrigger className="h-9 w-full sm:w-44">
<SelectValue placeholder="All channels" />
</SelectTrigger>
<SelectContent>
<SelectItem value=" ">All channels</SelectItem>
{channels.map((ch) => (
<SelectItem key={ch.id} value={ch.id}># {ch.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button variant="outline" size="sm" onClick={() => reanalyzeBatchMut.mutate(guildId)}>
<RefreshCw className="size-4 mr-1.5" />
Reanalyze Errors
</Button>
</div>
<Tabs value={viewTab} onValueChange={(v) => setViewTab(v as typeof viewTab)}>
<TabsList>
<TabsTrigger value="all">All ({(searchResults ?? messages)?.length ?? 0})</TabsTrigger>
<TabsTrigger value="images">Images ({images?.length ?? 0})</TabsTrigger>
<TabsTrigger value="review">
<Flag className="size-3.5 mr-1" /> Review ({reviews?.length ?? 0})
</TabsTrigger>
</TabsList>
</Tabs>
{searchResults && (
<p className="text-sm text-muted-foreground animate-fade-in-up">
Found {searchResults.length} result{searchResults.length !== 1 ? "s" : ""}
</p>
)}
{viewTab === "all" && (
<div className="space-y-2 animate-fade-in-up">
{isLoading ? (
<LoadingSkeleton count={8} height="h-28" />
) : isEmpty ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Search className="size-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
{searchResults ? "No messages found matching your search." : "No captures yet."}
</p>
</div>
) : (
<>
{displayMessages.map((msg) => (
<MessageCard key={msg.id} message={msg} onClick={setDetailId} onReanalyze={(id) => reanalyzeMut.mutate(id)} />
))}
{hasMore && (
<div className="flex justify-center py-6">
<Button variant="outline" onClick={handleLoadMore} disabled={loadMoreMut.isPending}>
{loadMoreMut.isPending && <Loader2 className="size-4 animate-spin mr-2" />}
{loadMoreMut.isPending ? "Loading..." : "Load more"}
</Button>
</div>
)}
</>
)}
</div>
)}
{viewTab === "images" && images && <ImagesGrid images={images} onClick={setDetailId} />}
{viewTab === "review" && reviews && (
<ReviewList reviews={reviews} onClick={setDetailId} onReanalyze={(id) => reanalyzeMut.mutate(id)} />
)}
<Dialog open={detailId !== null} onOpenChange={(o) => !o && setDetailId(null)}>
<DialogContent className="sm:max-w-2xl max-h-[85vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquare className="size-4" /> Message Detail
</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[70vh] pr-1">
{detailLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : detailMessage ? (
<MessageDetailView message={detailMessage} attachments={detailAttachments} />
) : null}
</ScrollArea>
</DialogContent>
</Dialog>
</div>
);
}
```
(Note: Need to add `MessageSquare` to lucide import)
- [ ] **Step 7: Typecheck**
```bash
cd /home/code/GMW/services/frontend && npx tsc --noEmit
```
- [ ] **Step 8: Commit**
```bash
git add src/components/messages/ src/app/\(dashboard\)/messages/page.tsx
git commit -m "refactor(frontend): extract message components from page"
```
---
### Task 5: Dashboard Feature Components
**Files:**
- Create: `src/components/dashboard/stats-section.tsx`
- Create: `src/components/dashboard/users-section.tsx`
- Create: `src/components/dashboard/user-detail-section.tsx`
- Create: `src/components/dashboard/channels-section.tsx`
- Create: `src/components/dashboard/channel-detail-section.tsx`
- Modify: `src/app/(dashboard)/dashboard/page.tsx`
Each component is extracted verbatim from the existing `dashboard/page.tsx` inline functions, preserving exact rendering. Structure same as Task 4 pattern.
- [ ] **Step 1-5: Create the 5 component files** — extract each inline section from `dashboard/page.tsx` into its own file under `src/components/dashboard/`. Each component gets:
- Same `"use client"` directive
- Same imports it needs
- Same JSX (no visual changes)
- Same props interface
- [ ] **Step 6: Simplify `dashboard/page.tsx`**
Replace with a thin composition layer that imports the 5 sections and uses the state machine pattern (view switching).
- [ ] **Step 7: Typecheck & commit**
```bash
cd /home/code/GMW/services/frontend && npx tsc --noEmit
git add src/components/dashboard/ src/app/\(dashboard\)/dashboard/page.tsx
git commit -m "refactor(frontend): extract dashboard components from page"
```
---
### Task 6: Extract Voice / Media / Recordings / Analysis Components
**Files:**
- Create: `src/components/voice/voice-connection-card.tsx`
- Create: `src/components/voice/active-speakers-panel.tsx`
- Create: `src/components/voice/microphone-card.tsx`
- Modify: `src/app/(dashboard)/voice/page.tsx`
- Create: `src/components/media/music-player.tsx`
- Modify: `src/app/(dashboard)/media/page.tsx`
- Create: `src/components/recordings/recording-list.tsx`
- Modify: `src/app/(dashboard)/recordings/page.tsx`
- Create: `src/components/analysis/search-panel.tsx`
- Modify: `src/app/(dashboard)/analysis/page.tsx`
Same pattern as Tasks 4-5: extract inline components to separate files, simplify page files.
- [ ] **Step 1-10: Create component files for each feature**
- [ ] **Step 11: Simplify page files**
- [ ] **Step 12: Typecheck & commit**
```bash
git add src/components/voice/ src/components/media/ src/components/recordings/ src/components/analysis/ src/app/\(dashboard\)/voice/page.tsx src/app/\(dashboard\)/media/page.tsx src/app/\(dashboard\)/recordings/page.tsx src/app/\(dashboard\)/analysis/page.tsx
git commit -m "refactor(frontend): extract voice/media/recordings/analysis components"
```
---
### Task 7: Data Fetching Consistency
**Files:**
- Modify: `src/components/shared/guild-selector.tsx`
- Modify: `src/hooks/use-voice.ts`
- Modify: `src/components/chatbot/chatbot.tsx`
- [ ] **Step 1: Refactor `GuildSelector` to use hooks**
Replace manual `useState` + `useEffect` + `fetchGuilds` with `useGuilds()` and `useConfig()` hooks. Handle loading/error states the same way.
```typescript
"use client";
import { AlertCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { useConfig, useGuilds } from "@/hooks";
import type { Guild } from "@/lib/types";
export interface GuildSelectorProps {
value: string;
onChange: (guildId: string) => void;
autoHide?: boolean;
}
export function GuildSelector({ value, onChange, autoHide = true }: GuildSelectorProps) {
const { data: guilds = [], isLoading, error, refetch } = useGuilds();
const { data: config } = useConfig();
// Auto-select on mount
const initDone = useRef(false);
useEffect(() => {
if (guilds.length === 0 || value || initDone.current) return;
initDone.current = true;
const preferred = config?.monitorGuildId ?? guilds[0].id;
if (preferred) onChange(preferred);
}, [guilds, config, value, onChange]);
if (autoHide && guilds.length <= 1 && !isLoading && !error) return null;
if (isLoading) {
return (
<div className="flex items-center gap-3 rounded-xl border border-border/50 bg-card p-3">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-8 w-8 rounded-full" />
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-between rounded-xl border border-destructive/20 bg-destructive/5 p-3">
<div className="flex items-center gap-2">
<AlertCircle className="size-4 text-destructive shrink-0" />
<p className="text-sm text-muted-foreground">
Could not load guilds: {error.message}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="size-3 mr-1" />
Retry
</Button>
</div>
);
}
if (guilds.length === 0) { /* same as current */ }
return (
<div className="flex items-center gap-3 rounded-xl border border-border/50 bg-card p-3">
<Badge variant="outline" className="shrink-0 text-xs font-normal">Guild</Badge>
<Select value={value} onValueChange={(v) => v && onChange(v)}>
<SelectTrigger className="h-8 w-full max-w-xs">
<SelectValue placeholder="Select a guild...">
{guilds.find((g) => g.id === value)?.name}
</SelectValue>
</SelectTrigger>
<SelectContent>
{guilds.map((g) => (
<SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
```
(Add `useRef`, `useEffect` and `RefreshCw` to imports.)
- [ ] **Step 2: Convert `useVoiceChannels` to React Query**
In `use-voice.ts`, replace:
```typescript
export function useVoiceChannels() {
const [channels, setChannels] = useState<...>([]);
...
}
```
With:
```typescript
import { useQuery } from "@tanstack/react-query";
export function useVoiceChannels(guildId: string) {
return useQuery<Channel[]>({
queryKey: ["voice-channels", guildId],
queryFn: () => voiceApi.getVoiceChannels(guildId),
enabled: !!guildId,
});
}
```
Then update `voice/page.tsx` where it calls `useVoiceChannels` — change from `const { channels, fetch } = useVoiceChannels()` to `const { data: voiceChannels = [], refetch: fetchChannels } = useVoiceChannels(selectedGuild)`.
- [ ] **Step 3: Refactor Chatbot to React Query**
In `chatbot.tsx`, add:
```typescript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
// Inside Chatbot component:
const qc = useQueryClient();
const { data: historyMessages = [] } = useQuery({
queryKey: ["chatbot-history"],
queryFn: () => chatbotApi.getHistory(),
enabled: open,
});
const sendMut = useMutation({
mutationFn: (text: string) => chatbotApi.send(text),
onSuccess: () => qc.invalidateQueries({ queryKey: ["chatbot-history"] }),
});
const clearMut = useMutation({
mutationFn: () => chatbotApi.clearHistory(),
onSuccess: () => qc.setQueryData(["chatbot-history"], []),
});
```
Replace manual `useEffect` history fetch with `historyMessages` from query.
Replace manual `handleSend` with `sendMut.mutateAsync`.
Replace manual `handleClear` with `clearMut.mutate`.
- [ ] **Step 4: Typecheck & commit**
```bash
cd /home/code/GMW/services/frontend && npx tsc --noEmit
git add src/components/shared/guild-selector.tsx src/hooks/use-voice.ts src/components/chatbot/chatbot.tsx src/app/\(dashboard\)/voice/page.tsx
git commit -m "refactor(frontend): consistent React Query data fetching"
```
---
### Task 8: Rebrand (bete/GMW → Discord Automod)
**Files:**
- Modify: `src/app/layout.tsx`
- Modify: `src/app/(dashboard)/settings/page.tsx`
- Modify: Any files with "bete" or "GMW" references in comments
- [ ] **Step 1: Search for "bete" and "GMW" references**
```bash
grep -rn -i "bete\|gmw" src/ --include="*.ts" --include="*.tsx"
```
- [ ] **Step 2: Update title in `src/app/layout.tsx`**
```typescript
export const metadata: Metadata = {
title: "Discord Automod — Moderation Dashboard",
description: "Live Discord monitoring and AI moderation dashboard",
};
```
- [ ] **Step 3: Update settings page text**
In `src/app/(dashboard)/settings/page.tsx`, the about section:
```typescript
<p><span className="text-gradient font-bold">Discord Automod</span> Discord Moderation Watcher</p>
```
- [ ] **Step 4: Update any remaining references** in comments or labels
- [ ] **Step 5: Commit**
```bash
git add src/app/layout.tsx src/app/\(dashboard\)/settings/page.tsx
git commit -m "refactor(frontend): rebrand bete/GMW to Discord Automod"
```
---
### Task 9: Remove Unused shadcn/ui Components
**Files:** Various under `src/components/ui/`
- [ ] **Step 1: Find unused shadcn/ui components**
```bash
cd /home/code/GMW/services/frontend/src
for f in components/ui/*.tsx; do
name=$(basename "$f" .tsx);
# Skip core components that might be gitignored or infrastructure
case "$name" in
sidebar|button|card|input|select|tabs|dialog|badge|avatar|progress|scroll-area|skeleton|slider|separator|switch|sonner|tooltip|sheet|label|popover|command|dropdown-menu) continue ;;
esac
count=$(grep -r "components/ui/$name" app/ components/ hooks/ lib/ --include="*.tsx" --include="*.ts" -l 2>/dev/null | grep -v "components/ui/$name" | wc -l);
echo "$name: $count imports";
done | sort -t: -k2 -n
```
- [ ] **Step 2: Remove components with 0 imports**
For each component with 0 imports (excluding self-imports), delete the file.
- [ ] **Step 3: Verify nothing breaks**
```bash
cd /home/code/GMW/services/frontend && npx tsc --noEmit
```
- [ ] **Step 4: Commit**
```bash
git add src/components/ui/
git commit -m "refactor(frontend): remove unused shadcn/ui components"
```
---
## Verification
After all tasks complete:
```bash
cd /home/code/GMW/services/frontend
npx tsc --noEmit
```
Expected: No type errors.
```bash
npx next build 2>&1 | tail -20
```
Expected: Successful static export build with no warnings.
## Rollback Plan
If any step breaks the build:
1. `git log --oneline -10` to see recent commits
2. `git revert <commit>` to revert specific change
3. Or `git reset --hard HEAD~N` to roll back multiple commits