refactor: rename mascot to chatbot across entire codebase
Build & Deploy / build-and-push (discord-gateway) (push) Failing after 30s
Build & Deploy / build-and-push (backend) (push) Failing after 2m28s
Build & Deploy / build-and-push (proxy) (push) Successful in 2m28s

- Backend: mascot-chat module → chatbot, routes /mascot/chat → /chat
- Shared schema: pgMascotChatMessagesTable → pgChatbotMessagesTable
- Frontend: MascotProvider/useMascot → ChatbotProvider/useChatbot
- Gateway schema: update exports to match shared schema
- Docs: update all .md references (CLAUDE.md, README, specs, plans)
- All API routes, controller names, service classes, types renamed

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Developer
2026-07-28 15:00:29 +07:00
co-authored by Claude Opus 4.8
parent bd9e7d8151
commit 977a6f9653
38 changed files with 243 additions and 1221 deletions
+3 -3
View File
@@ -80,7 +80,7 @@ Express 5 + Helmet HTTP server with WebSocket (ws) on port 3001 (default).
- `POST /api/messages/:id/moderate` — Dispatch moderation action - `POST /api/messages/:id/moderate` — Dispatch moderation action
- `GET /api/review` — Flagged/warned messages - `GET /api/review` — Flagged/warned messages
- `GET /api/analysis/search` — Full-text search with `?q=` - `GET /api/analysis/search` — Full-text search with `?q=`
- `POST /api/chat`Mascot AI chat - `POST /api/chat`AI chatbot chat
- `GET /api/chat/history` — Chat history - `GET /api/chat/history` — Chat history
- `POST /api/chat/clear` — Clear chat history - `POST /api/chat/clear` — Clear chat history
- `POST /api/voice/command` — Send voice transmit commands - `POST /api/voice/command` — Send voice transmit commands
@@ -105,7 +105,7 @@ Express 5 + Helmet HTTP server with WebSocket (ws) on port 3001 (default).
- `voice/` — Voice connection, guilds, channels - `voice/` — Voice connection, guilds, channels
- `media/` — Music/screenshare player control - `media/` — Music/screenshare player control
- `analysis/` — Full-text search across analyzed messages - `analysis/` — Full-text search across analyzed messages
- `mascot-chat/` — AI chatbot with server context - `chatbot/` — AI chatbot with server context
- `recordings/` — Voice recording listing - `recordings/` — Voice recording listing
- `ui-state/` — Persistent UI state for dashboard - `ui-state/` — Persistent UI state for dashboard
- `config/` — App config endpoint - `config/` — App config endpoint
@@ -205,7 +205,7 @@ Next.js 16 (React 19) static export dashboard, built with TypeScript + Tailwind
**Feature structure:** **Feature structure:**
- `src/app/` — App Router pages (login, dashboard with tabs) - `src/app/` — App Router pages (login, dashboard with tabs)
- `src/features/` — Feature components (dashboard, messages, live, mascot) - `src/features/` — Feature components (dashboard, messages, live, chatbot)
- `src/lib/` — Shared utilities (types, API client, WebSocket, hooks) - `src/lib/` — Shared utilities (types, API client, WebSocket, hooks)
- `src/components/` — Shared UI components (layout, ui) - `src/components/` — Shared UI components (layout, ui)
@@ -2,7 +2,7 @@
> **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. > **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. **Goal:** Full frontend redesign with glassmorphic dark theme, floating top nav, Live2D chatbot, 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. **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.
@@ -33,7 +33,7 @@
| `src/app/globals.css` | Complete rewrite — new tokens, glass system, animations | | `src/app/globals.css` | Complete rewrite — new tokens, glass system, animations |
| `src/app/layout.tsx` | Fonts (Inter + JetBrains Mono), metadata | | `src/app/layout.tsx` | Fonts (Inter + JetBrains Mono), metadata |
| `src/app/page.tsx` | Redirect `/dashboard` not `/messages` | | `src/app/page.tsx` | Redirect `/dashboard` not `/messages` |
| `src/app/(dashboard)/layout.tsx` | Top nav, no sidebar, mascot context, media context, WS provider | | `src/app/(dashboard)/layout.tsx` | Top nav, no sidebar, chatbot context, media context, WS provider |
| `src/lib/navigation.ts` | New nav items (no Search link), mobile items updated | | `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)/dashboard/page.tsx` | Full rewrite — Ops Center |
| `src/app/(dashboard)/messages/page.tsx` | Full rewrite — Split pane | | `src/app/(dashboard)/messages/page.tsx` | Full rewrite — Split pane |
@@ -69,23 +69,23 @@
| `src/components/voice/activity-timeline.tsx` | Voice activity chart | | `src/components/voice/activity-timeline.tsx` | Voice activity chart |
| `src/components/recordings/recording-card.tsx` | Glass card + waveform preview | | `src/components/recordings/recording-card.tsx` | Glass card + waveform preview |
| `src/components/recordings/recording-player.tsx` | Inline audio player | | `src/components/recordings/recording-player.tsx` | Inline audio player |
| `src/components/mascot/mascot-container.tsx` | Floating L2D container | | `src/components/chatbot/chatbot-container.tsx` | Floating L2D container |
| `src/components/mascot/mascot-canvas.tsx` | WebGL Live2D renderer | | `src/components/chatbot/chatbot-canvas.tsx` | WebGL Live2D renderer |
| `src/components/mascot/chat-panel.tsx` | Chat input + history | | `src/components/chatbot/chat-panel.tsx` | Chat input + history |
| `src/components/mascot/mascot-context.tsx` | Context provider | | `src/components/chatbot/chatbot-context.tsx` | Context provider |
| `src/components/media/mini-player.tsx` | Floating media player | | `src/components/media/mini-player.tsx` | Floating media player |
| `src/components/shared/error-boundary.tsx` | Per-page error boundary | | `src/components/shared/error-boundary.tsx` | Per-page error boundary |
| `src/components/shared/loading-skeleton.tsx` | Glass shimmer skeleton | | `src/components/shared/loading-skeleton.tsx` | Glass shimmer skeleton |
| `src/components/shared/empty-state.tsx` | Empty state | | `src/components/shared/empty-state.tsx` | Empty state |
| `src/lib/hooks/use-media-player.ts` | Global media player context | | `src/lib/hooks/use-media-player.ts` | Global media player context |
| `src/lib/hooks/use-mascot.ts` | Mascot context hook | | `src/lib/hooks/use-chatbot.ts` | Chatbot context hook |
### Deleted files (replaced by new components): ### Deleted files (replaced by new components):
| File | Replaced by | | File | Replaced by |
|------|-------------| |------|-------------|
| `src/components/layout/app-sidebar.tsx` | `top-nav.tsx` + `hidden-sidebar.tsx` | | `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/layout/app-header.tsx` | `top-nav.tsx` + `sub-nav.tsx` |
| `src/components/chatbot/chatbot.tsx` | `mascot/` components | | `src/components/chatbot/chatbot.tsx` | `chatbot/` components |
| `src/components/shared/stat-card.tsx` | `dashboard/stat-card.tsx` | | `src/components/shared/stat-card.tsx` | `dashboard/stat-card.tsx` |
| `src/components/shared/detail-stat.tsx` | inline in detail views | | `src/components/shared/detail-stat.tsx` | inline in detail views |
| `src/components/messages/images-grid.tsx` | `attachments-grid.tsx` | | `src/components/messages/images-grid.tsx` | `attachments-grid.tsx` |
@@ -749,8 +749,8 @@ import { Suspense } from "react";
import { TopNav } from "@/components/layout/top-nav"; import { TopNav } from "@/components/layout/top-nav";
import { MobileNav } from "@/components/layout/mobile-nav"; import { MobileNav } from "@/components/layout/mobile-nav";
import { WsProvider } from "@/lib/ws/context"; import { WsProvider } from "@/lib/ws/context";
import { MascotProvider } from "@/components/mascot/mascot-context"; import { ChatbotProvider } from "@/components/chatbot/chatbot-context";
import { MascotContainer } from "@/components/mascot/mascot-container"; import { ChatbotContainer } from "@/components/chatbot/chatbot-container";
import { MiniPlayer } from "@/components/media/mini-player"; import { MiniPlayer } from "@/components/media/mini-player";
import { MediaPlayerProvider } from "@/lib/hooks/use-media-player"; import { MediaPlayerProvider } from "@/lib/hooks/use-media-player";
import { HiddenSidebar } from "@/components/layout/hidden-sidebar"; import { HiddenSidebar } from "@/components/layout/hidden-sidebar";
@@ -777,7 +777,7 @@ export default function DashboardLayout({
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<WsProvider> <WsProvider>
<MediaPlayerProvider> <MediaPlayerProvider>
<MascotProvider> <ChatbotProvider>
<div className="min-h-screen bg-canvas"> <div className="min-h-screen bg-canvas">
<TopNav /> <TopNav />
<HiddenSidebar guildId={guildId} onGuildChange={(g) => setGuildId(g ?? "")} /> <HiddenSidebar guildId={guildId} onGuildChange={(g) => setGuildId(g ?? "")} />
@@ -799,9 +799,9 @@ export default function DashboardLayout({
<MobileNav /> <MobileNav />
<MiniPlayer /> <MiniPlayer />
<MascotContainer /> <ChatbotContainer />
</div> </div>
</MascotProvider> </ChatbotProvider>
</MediaPlayerProvider> </MediaPlayerProvider>
</WsProvider> </WsProvider>
</QueryClientProvider> </QueryClientProvider>
@@ -822,7 +822,7 @@ rm src/components/chatbot/chatbot.tsx
```bash ```bash
git add src/app/\(dashboard\)/layout.tsx 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 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" git commit -m "feat: rewrite dashboard layout with top nav, hidden sidebar, chatbot, mini-player"
``` ```
--- ---
@@ -3117,38 +3117,38 @@ git commit -m "feat: add media player context and floating mini player"
--- ---
### Task 21: Mascot — Context, Container & Canvas ### Task 21: Chatbot — Context, Container & Canvas
**Files:** **Files:**
- Create: `src/components/mascot/mascot-context.tsx` - Create: `src/components/chatbot/chatbot-context.tsx`
- Create: `src/components/mascot/mascot-container.tsx` - Create: `src/components/chatbot/chatbot-container.tsx`
- Create: `src/components/mascot/mascot-canvas.tsx` - Create: `src/components/chatbot/chatbot-canvas.tsx`
- Create: `src/components/mascot/chat-panel.tsx` - Create: `src/components/chatbot/chat-panel.tsx`
- [ ] **Step 1: Create MascotContext** - [ ] **Step 1: Create ChatbotContext**
```tsx ```tsx
"use client"; "use client";
import { createContext, useContext, useState, type ReactNode } from "react"; import { createContext, useContext, useState, type ReactNode } from "react";
type MascotExpression = "idle" | "listening" | "surprise" | "happy" | "sad" | "talking"; type ChatbotExpression = "idle" | "listening" | "surprise" | "happy" | "sad" | "talking";
interface MascotContextType { interface ChatbotContextType {
expression: MascotExpression; expression: ChatbotExpression;
minimized: boolean; minimized: boolean;
chatOpen: boolean; chatOpen: boolean;
chatHistory: { role: "user" | "assistant"; text: string }[]; chatHistory: { role: "user" | "assistant"; text: string }[];
setExpression: (expr: MascotExpression) => void; setExpression: (expr: ChatbotExpression) => void;
setMinimized: (v: boolean) => void; setMinimized: (v: boolean) => void;
setChatOpen: (v: boolean) => void; setChatOpen: (v: boolean) => void;
addChat: (role: "user" | "assistant", text: string) => void; addChat: (role: "user" | "assistant", text: string) => void;
} }
const MascotContext = createContext<MascotContextType | null>(null); const ChatbotContext = createContext<ChatbotContextType | null>(null);
export function MascotProvider({ children }: { children: ReactNode }) { export function ChatbotProvider({ children }: { children: ReactNode }) {
const [expression, setExpression] = useState<MascotExpression>("idle"); const [expression, setExpression] = useState<ChatbotExpression>("idle");
const [minimized, setMinimized] = useState(true); const [minimized, setMinimized] = useState(true);
const [chatOpen, setChatOpen] = useState(false); const [chatOpen, setChatOpen] = useState(false);
const [chatHistory, setChatHistory] = useState<{ role: "user" | "assistant"; text: string }[]>([]); const [chatHistory, setChatHistory] = useState<{ role: "user" | "assistant"; text: string }[]>([]);
@@ -3158,28 +3158,28 @@ export function MascotProvider({ children }: { children: ReactNode }) {
}; };
return ( return (
<MascotContext.Provider <ChatbotContext.Provider
value={{ expression, minimized, chatOpen, chatHistory, setExpression, setMinimized, setChatOpen, addChat }} value={{ expression, minimized, chatOpen, chatHistory, setExpression, setMinimized, setChatOpen, addChat }}
> >
{children} {children}
</MascotContext.Provider> </ChatbotContext.Provider>
); );
} }
export function useMascot() { export function useChatbot() {
const ctx = useContext(MascotContext); const ctx = useContext(ChatbotContext);
if (!ctx) throw new Error("useMascot must be used within MascotProvider"); if (!ctx) throw new Error("useChatbot must be used within ChatbotProvider");
return ctx; return ctx;
} }
``` ```
- [ ] **Step 2: Create MascotCanvas (Live2D placeholder)** - [ ] **Step 2: Create ChatbotCanvas (Live2D placeholder)**
```tsx ```tsx
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useMascot } from "./mascot-context"; import { useChatbot } from "./chatbot-context";
/** /**
* Live2D Cubism WebGL canvas. * Live2D Cubism WebGL canvas.
@@ -3188,15 +3188,15 @@ import { useMascot } from "./mascot-context";
* Integration requires: * Integration requires:
* 1. Live2D Cubism SDK for Web (npm: @live2d/cubism) * 1. Live2D Cubism SDK for Web (npm: @live2d/cubism)
* 2. Model files: .model3.json, .moc3, .physics3.json, textures * 2. Model files: .model3.json, .moc3, .physics3.json, textures
* 3. Place model files in public/mascot/ * 3. Place model files in public/chatbot/
* *
* The current implementation shows a placeholder character. * The current implementation shows a placeholder character.
* Replace with actual Cubism SDK integration when model files are available. * Replace with actual Cubism SDK integration when model files are available.
*/ */
export function MascotCanvas() { export function ChatbotCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const { expression } = useMascot(); const { expression } = useChatbot();
// Placeholder: draw a simple avatar face that responds to expression // Placeholder: draw a simple avatar face that responds to expression
useEffect(() => { useEffect(() => {
@@ -3308,19 +3308,19 @@ export function MascotCanvas() {
} }
``` ```
- [ ] **Step 3: Create MascotContainer** - [ ] **Step 3: Create ChatbotContainer**
```tsx ```tsx
"use client"; "use client";
import { MessageCircle, X, Minimize2, Maximize2 } from "lucide-react"; import { MessageCircle, X, Minimize2, Maximize2 } from "lucide-react";
import { useMascot } from "./mascot-context"; import { useChatbot } from "./chatbot-context";
import { MascotCanvas } from "./mascot-canvas"; import { ChatbotCanvas } from "./chatbot-canvas";
import { ChatPanel } from "./chat-panel"; import { ChatPanel } from "./chat-panel";
import { useState } from "react"; import { useState } from "react";
export function MascotContainer() { export function ChatbotContainer() {
const { minimized, setMinimized, chatOpen, setChatOpen } = useMascot(); const { minimized, setMinimized, chatOpen, setChatOpen } = useChatbot();
const [position, setPosition] = useState({ x: 0, y: 0 }); const [position, setPosition] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
@@ -3345,7 +3345,7 @@ export function MascotContainer() {
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp} onMouseLeave={handleMouseUp}
> >
{/* Main mascot bubble */} {/* Main chatbot bubble */}
<div <div
className={`glass-intense rounded-2xl overflow-hidden transition-all duration-200 ${ className={`glass-intense rounded-2xl overflow-hidden transition-all duration-200 ${
minimized ? "w-16 h-16 cursor-pointer" : "w-[200px]" minimized ? "w-16 h-16 cursor-pointer" : "w-[200px]"
@@ -3368,7 +3368,7 @@ export function MascotContainer() {
className="flex items-center justify-between px-3 py-1.5 border-b border-glass-border cursor-grab active:cursor-grabbing" className="flex items-center justify-between px-3 py-1.5 border-b border-glass-border cursor-grab active:cursor-grabbing"
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
> >
<span className="text-[10px] font-semibold text-text-secondary tracking-wide uppercase">Mascot</span> <span className="text-[10px] font-semibold text-text-secondary tracking-wide uppercase">Chatbot</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button type="button" onClick={() => setChatOpen(!chatOpen)}> <button type="button" onClick={() => setChatOpen(!chatOpen)}>
<MessageCircle className="size-3 text-text-secondary/60 hover:text-text-primary" /> <MessageCircle className="size-3 text-text-secondary/60 hover:text-text-primary" />
@@ -3381,7 +3381,7 @@ export function MascotContainer() {
{/* Canvas area */} {/* Canvas area */}
<div className="h-[140px] flex items-center justify-center"> <div className="h-[140px] flex items-center justify-center">
<MascotCanvas /> <ChatbotCanvas />
</div> </div>
{/* Chat panel (expandable) */} {/* Chat panel (expandable) */}
@@ -3403,10 +3403,10 @@ export function MascotContainer() {
import { Send } from "lucide-react"; import { Send } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useMascot } from "./mascot-context"; import { useChatbot } from "./chatbot-context";
export function ChatPanel() { export function ChatPanel() {
const { chatHistory, addChat, setExpression } = useMascot(); const { chatHistory, addChat, setExpression } = useChatbot();
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const handleSend = () => { const handleSend = () => {
@@ -3414,7 +3414,7 @@ export function ChatPanel() {
addChat("user", input); addChat("user", input);
setExpression("listening"); setExpression("listening");
// Simulated bot response — replace with actual mascot-chat API call // Simulated bot response — replace with actual chatbot-chat API call
setTimeout(() => { setTimeout(() => {
addChat("assistant", `I'm monitoring this server for you!`); addChat("assistant", `I'm monitoring this server for you!`);
setExpression("happy"); setExpression("happy");
@@ -3444,7 +3444,7 @@ export function ChatPanel() {
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()} onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Ask mascot..." placeholder="Ask chatbot..."
className="flex-1 bg-transparent text-[10px] text-text-primary placeholder-text-secondary/30 outline-none" className="flex-1 bg-transparent text-[10px] text-text-primary placeholder-text-secondary/30 outline-none"
/> />
<button type="button" onClick={handleSend} className="size-5 flex items-center justify-center"> <button type="button" onClick={handleSend} className="size-5 flex items-center justify-center">
@@ -3459,17 +3459,17 @@ export function ChatPanel() {
- [ ] **Step 5: Create barrel export** - [ ] **Step 5: Create barrel export**
```tsx ```tsx
// src/components/mascot/index.ts // src/components/chatbot/index.ts
export { MascotProvider } from "./mascot-context"; export { ChatbotProvider } from "./chatbot-context";
export { MascotContainer } from "./mascot-container"; export { ChatbotContainer } from "./chatbot-container";
export { useMascot } from "./mascot-context"; export { useChatbot } from "./chatbot-context";
``` ```
- [ ] **Step 6: Commit** - [ ] **Step 6: Commit**
```bash ```bash
git add src/components/mascot/ git add src/components/chatbot/
git commit -m "feat: add Live2D mascot container with canvas, chat panel, and context" git commit -m "feat: add Live2D chatbot container with canvas, chat panel, and context"
``` ```
--- ---
@@ -3477,21 +3477,21 @@ git commit -m "feat: add Live2D mascot container with canvas, chat panel, and co
### Task 22: WS Expression Triggers ### Task 22: WS Expression Triggers
**Files:** **Files:**
- Modify: `src/app/(dashboard)/layout.tsx` — add WS → mascot expression bindings - Modify: `src/app/(dashboard)/layout.tsx` — add WS → chatbot expression bindings
- [ ] **Step 1: Add WebSocket expression triggers** - [ ] **Step 1: Add WebSocket expression triggers**
In the dashboard layout, add a side-effect that connects WebSocket events to mascot expressions: In the dashboard layout, add a side-effect that connects WebSocket events to chatbot expressions:
```tsx ```tsx
// Add to dashboard layout before the return: // Add to dashboard layout before the return:
import { useEffect } from "react"; import { useEffect } from "react";
import { useMascot } from "@/components/mascot/mascot-context"; import { useChatbot } from "@/components/chatbot/chatbot-context";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
function MascotExpressionSync() { function ChatbotExpressionSync() {
const ws = useWebSocket(); const ws = useWebSocket();
const { setExpression } = useMascot(); const { setExpression } = useChatbot();
useEffect(() => { useEffect(() => {
const unsub1 = ws.on("message_created", (data: any) => { const unsub1 = ws.on("message_created", (data: any) => {
@@ -3512,13 +3512,13 @@ function MascotExpressionSync() {
} }
``` ```
Then render `<MascotExpressionSync />` inside the layout tree. Then render `<ChatbotExpressionSync />` inside the layout tree.
- [ ] **Step 2: Commit** - [ ] **Step 2: Commit**
```bash ```bash
git add src/app/\(dashboard\)/layout.tsx git add src/app/\(dashboard\)/layout.tsx
git commit -m "feat: connect WS events to mascot expression triggers" git commit -m "feat: connect WS events to chatbot expression triggers"
``` ```
--- ---
@@ -3570,12 +3570,12 @@ git commit -m "chore: remove old components replaced by redesign"
- Section 8 (Tech Stack) → Task 2 (fonts) - Section 8 (Tech Stack) → Task 2 (fonts)
- Section 9 (File Structure) → All tasks - Section 9 (File Structure) → All tasks
- Section 10 (Implementation Order) → Followed as-is - Section 10 (Implementation Order) → Followed as-is
- Mascot → Tasks 21, 22 - Chatbot → Tasks 21, 22
- Media Player → Task 20 - Media Player → Task 20
- No gaps found. - 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. - [ ] **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. - [ ] **Type consistency:** All component props match what consuming pages expect. Hook interfaces consistent (useChatbot, 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. - [ ] **No contradictions:** nav items match top nav links. Page layouts match sub-nav tabs. No file referenced before being created.
@@ -1,7 +1,7 @@
# Frontend Refactor: Cleanup, API Alignment & Rebrand # Frontend Refactor: Cleanup, API Alignment & Rebrand
## Goal ## Goal
Refactor the frontend (`services/frontend/`) to be cleaner, more maintainable, properly aligned with backend API, and rebranded from "bete/GMW" to "Discord Automod" and from "mascot" to "chatbot". Refactor the frontend (`services/frontend/`) to be cleaner, more maintainable, properly aligned with backend API, and rebranded from "bete/GMW" to "Discord Automod" and from "chatbot" to "chatbot".
## Scope ## Scope
@@ -23,7 +23,7 @@ Refactor the frontend (`services/frontend/`) to be cleaner, more maintainable, p
### D. Rebrand ### D. Rebrand
- **bete/GMW → Discord Automod**: page title, sidebar, settings, comments - **bete/GMW → Discord Automod**: page title, sidebar, settings, comments
- **mascot → chatbot**: the frontend already uses "chatbot" naming for the component and API module; backend paths (`/api/mascot/chat`) stay unchanged on frontend since they reference the actual backend path - **chatbot → chatbot**: the frontend already uses "chatbot" naming for the component and API module; backend paths (`/api/chatbot/chat`) stay unchanged on frontend since they reference the actual backend path
### E. Dead Code Removal ### E. Dead Code Removal
- Remove `components/landing/` (including `live-stats.tsx`) - Remove `components/landing/` (including `live-stats.tsx`)
@@ -13,7 +13,7 @@ Comprehensive refactoring of `services/backend` (4.2k lines) and `services/disco
**Problem:** Two competing controller patterns. **Problem:** Two competing controller patterns.
- `messages.controller.ts`, `mascot-chat.controller.ts` use convoluted `asyncHandler` inside function body (Gaya A) - `messages.controller.ts`, `chatbot-chat.controller.ts` use convoluted `asyncHandler` inside function body (Gaya A)
- `voice.controller.ts`, `health.controller.ts` use clean `asyncHandler` decorator (Gaya B) - `voice.controller.ts`, `health.controller.ts` use clean `asyncHandler` decorator (Gaya B)
**Fix:** Convert all controllers to **Gaya B** (decorator pattern). **Fix:** Convert all controllers to **Gaya B** (decorator pattern).
@@ -36,7 +36,7 @@ export const handleListMessages = asyncHandler(async (req, res) => {
**Files affected:** **Files affected:**
- `modules/messages/messages.controller.ts` - `modules/messages/messages.controller.ts`
- `modules/mascot-chat/mascot-chat.controller.ts` - `modules/chatbot-chat/chatbot-chat.controller.ts`
### Phase 2 — Backend `response.ts` Cleanup ### Phase 2 — Backend `response.ts` Cleanup
@@ -47,7 +47,7 @@ Full frontend redesign for Discord Automod, a Discord moderation watcher dashboa
Key pillars: Key pillars:
- **Immersion** — Full-viewport canvas with ambient motion, glass panels float over content - **Immersion** — Full-viewport canvas with ambient motion, glass panels float over content
- **Awareness** — Live data streams, real-time voice waveforms, animated moderation alerts - **Awareness** — Live data streams, real-time voice waveforms, animated moderation alerts
- **Presence** — Live2D vtuber mascot character as chatbot interface, reacts to server events - **Presence** — Live2D vtuber chatbot character as chatbot interface, reacts to server events
--- ---
@@ -69,7 +69,7 @@ Key pillars:
│ └─────────┘ └──────────────┘ │ │ └─────────┘ └──────────────┘ │
│ │ │ │
├──────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────┤
│ 🎵 [Mini-player] ← bottom-left 🎭 [Mascot] ← BR │ │ 🎵 [Mini-player] ← bottom-left 🎭 [Chatbot] ← BR │
└──────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────┘
``` ```
@@ -196,9 +196,9 @@ Custom theme matching design tokens:
- Heatmap: activity by hour × weekday - Heatmap: activity by hour × weekday
- Radar: multi-axis for moderation categories - Radar: multi-axis for moderation categories
### 4.5 Live2D Mascot / Chatbot ### 4.5 Live2D Chatbot / Chatbot
- Replaces the existing `Chatbot` component entirely — mascot panel is the new chat interface - Replaces the existing `Chatbot` component entirely — chatbot panel is the new chat interface
- **Location:** Floating panel, bottom-right corner, draggable - **Location:** Floating panel, bottom-right corner, draggable
- **Default size:** Compact — upper body visible (~200×280px) - **Default size:** Compact — upper body visible (~200×280px)
- **Click character:** Expand with full chat panel - **Click character:** Expand with full chat panel
@@ -232,7 +232,7 @@ Full-viewport command center:
- **Message Trend Chart:** 7-day area chart - **Message Trend Chart:** 7-day area chart
- **Activity Heatmap:** Hour × day-of-week, moderation event density - **Activity Heatmap:** Hour × day-of-week, moderation event density
- **Top Channels:** Bar chart with channel names - **Top Channels:** Bar chart with channel names
- **Mascot visible** floating bottom-right - **Chatbot visible** floating bottom-right
### 5.2 Messages — Split Pane ### 5.2 Messages — Split Pane
@@ -334,8 +334,8 @@ Optimistic cache updates for real-time data
### 7.4 Global State (React Context) ### 7.4 Global State (React Context)
- `useMediaPlayer()` — current track, queue, play/skip/stop/volume - `useMediaPlayer()` — current track, queue, play/skip/stop/volume
- `useMascot()` — expression, minimized, chatHistory, setExpression - `useChatbot()` — expression, minimized, chatHistory, setExpression
- Externally triggerable: `mascot.setExpression("surprise")` on flagged message, `("listening")` on voice activity - Externally triggerable: `chatbot.setExpression("surprise")` on flagged message, `("listening")` on voice activity
### 7.5 URL State ### 7.5 URL State
@@ -359,7 +359,7 @@ Each page has its own error boundary. One page failure doesn't affect others.
- **Icons:** lucide-react - **Icons:** lucide-react
- **State/data:** @tanstack/react-query v5 - **State/data:** @tanstack/react-query v5
- **Charts:** Recharts 3.8 (with custom theme) - **Charts:** Recharts 3.8 (with custom theme)
- **3D/Mascot:** Live2D Cubism SDK WebGL (pixi.js wrapper) - **3D/Chatbot:** Live2D Cubism SDK WebGL (pixi.js wrapper)
- **Audio:** Web Audio API for waveform visualization - **Audio:** Web Audio API for waveform visualization
- **Animation:** CSS animations + transitions (no GSAP/framer-motion dependency unless specifically needed) - **Animation:** CSS animations + transitions (no GSAP/framer-motion dependency unless specifically needed)
@@ -374,7 +374,7 @@ src/
│ ├── page.tsx # Redirect → /dashboard │ ├── page.tsx # Redirect → /dashboard
│ ├── globals.css # Complete redesign CSS (tokens, glass, animations) │ ├── globals.css # Complete redesign CSS (tokens, glass, animations)
│ └── (dashboard)/ │ └── (dashboard)/
│ ├── layout.tsx # Dashboard layout (top nav, QueryClient, WS, mascot) │ ├── layout.tsx # Dashboard layout (top nav, QueryClient, WS, chatbot)
│ ├── dashboard/ │ ├── dashboard/
│ │ └── page.tsx # Ops Center │ │ └── page.tsx # Ops Center
│ ├── messages/ │ ├── messages/
@@ -424,11 +424,11 @@ src/
│ │ ├── recording-card.tsx # Glass card with waveform preview │ │ ├── recording-card.tsx # Glass card with waveform preview
│ │ └── recording-player.tsx # Inline audio player │ │ └── recording-player.tsx # Inline audio player
│ │ │ │
│ ├── mascot/ │ ├── chatbot/
│ │ ├── mascot-container.tsx # Floating L2D container │ │ ├── chatbot-container.tsx # Floating L2D container
│ │ ├── mascot-canvas.tsx # WebGL canvas for L2D rendering │ │ ├── chatbot-canvas.tsx # WebGL canvas for L2D rendering
│ │ ├── chat-panel.tsx # Chat input + history │ │ ├── chat-panel.tsx # Chat input + history
│ │ └── mascot-context.tsx # Context provider │ │ └── chatbot-context.tsx # Context provider
│ │ │ │
│ ├── media/ │ ├── media/
│ │ └── mini-player.tsx # Floating mini media player │ │ └── mini-player.tsx # Floating mini media player
@@ -448,7 +448,7 @@ src/
│ │ └── types.ts # WS event types │ │ └── types.ts # WS event types
│ ├── hooks/ # Existing hooks + new ones │ ├── hooks/ # Existing hooks + new ones
│ │ ├── use-media-player.ts # Global media state │ │ ├── use-media-player.ts # Global media state
│ │ ├── use-mascot.ts # Mascot context hook │ │ ├── use-chatbot.ts # Chatbot context hook
│ │ └── use-heatmap.ts # Heatmap data hook │ │ └── use-heatmap.ts # Heatmap data hook
│ ├── types/ # Existing types (unchanged) │ ├── types/ # Existing types (unchanged)
│ ├── navigation.ts # Nav items (updated) │ ├── navigation.ts # Nav items (updated)
@@ -486,7 +486,7 @@ src/
### Phase 5 — Floating Elements ### Phase 5 — Floating Elements
18. Build `mini-player.tsx` for media 18. Build `mini-player.tsx` for media
19. Build mascot components (L2D integration) 19. Build chatbot components (L2D integration)
--- ---
@@ -495,7 +495,7 @@ src/
- Visual regression checks per component - Visual regression checks per component
- WS integration tests for cache updates - WS integration tests for cache updates
- Responsive breakpoint testing (mobile bottom nav) - Responsive breakpoint testing (mobile bottom nav)
- L2D mascot load + expression trigger - L2D chatbot load + expression trigger
--- ---
+11 -11
View File
@@ -502,30 +502,30 @@ export const pgRetentionPoliciesTable = pgTable(
export const retentionPoliciesTable = pgRetentionPoliciesTable; export const retentionPoliciesTable = pgRetentionPoliciesTable;
/** /**
* Mascot Chat Messages Table (PostgreSQL) * Chatbot Messages Table (PostgreSQL)
* Stores AI mascot chat conversation history * Stores AI chat conversation history
*/ */
export const pgMascotChatMessagesTable = pgTable( export const pgChatbotMessagesTable = pgTable(
"mascot_chat_messages", "chatbot_messages",
{ {
id: pgUuid("id").defaultRandom().primaryKey(), id: pgUuid("id").defaultRandom().primaryKey(),
user_id: pgText("user_id").notNull(), user_id: pgText("user_id").notNull(),
user_message: pgText("user_message").notNull(), user_message: pgText("user_message").notNull(),
mascot_response: pgText("mascot_response").notNull(), bot_response: pgText("bot_response").notNull(),
context: pgJsonb("context").notNull().default("{}"), context: pgJsonb("context").notNull().default("{}"),
created_at: pgTimestamp("created_at", { withTimezone: true, mode: "date" }) created_at: pgTimestamp("created_at", { withTimezone: true, mode: "date" })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
}, },
(table) => ({ (table) => ({
userCreatedIdx: pgIndex("idx_mascot_chat_messages_user_created").on( userCreatedIdx: pgIndex("idx_chatbot_messages_user_created").on(
table.user_id, table.user_id,
table.created_at.desc(), table.created_at.desc(),
), ),
}), }),
); );
export const mascotChatMessagesTable = pgMascotChatMessagesTable; export const chatbotMessagesTable = pgChatbotMessagesTable;
// ============================================================================= // =============================================================================
// Type Exports // Type Exports
@@ -590,7 +590,7 @@ export type DbRetentionPolicy = typeof retentionPoliciesTable.$inferSelect;
export type DbRetentionPolicyInsert = export type DbRetentionPolicyInsert =
typeof retentionPoliciesTable.$inferInsert; typeof retentionPoliciesTable.$inferInsert;
// Mascot Chat Messages // Chatbot Messages
export type MascotChatMessage = typeof mascotChatMessagesTable.$inferSelect; export type ChatbotMessage = typeof chatbotMessagesTable.$inferSelect;
export type MascotChatMessageInsert = export type ChatbotMessageInsert =
typeof mascotChatMessagesTable.$inferInsert; typeof chatbotMessagesTable.$inferInsert;
+2 -2
View File
@@ -10,7 +10,7 @@ import { createAnalysisRouter } from "../modules/analysis/index.js";
import { createConfigRouter } from "../modules/config/index.js"; import { createConfigRouter } from "../modules/config/index.js";
import { createDashboardRouter } from "../modules/dashboard/index.js"; import { createDashboardRouter } from "../modules/dashboard/index.js";
import { createHealthRouter } from "../modules/health/index.js"; import { createHealthRouter } from "../modules/health/index.js";
import { createMascotChatRouter } from "../modules/mascot-chat/index.js"; import { createChatbotRouter } from "../modules/chatbot/index.js";
import { createMediaRouter } from "../modules/media/index.js"; import { createMediaRouter } from "../modules/media/index.js";
import { createMessagesRouter } from "../modules/messages/index.js"; import { createMessagesRouter } from "../modules/messages/index.js";
import { createRecordingsRouter } from "../modules/recordings/index.js"; import { createRecordingsRouter } from "../modules/recordings/index.js";
@@ -64,7 +64,7 @@ export function createHttpApp(): Express {
app.use("/api", createDashboardRouter()); app.use("/api", createDashboardRouter());
app.use("/api", createMessagesRouter()); app.use("/api", createMessagesRouter());
app.use("/api", createAnalysisRouter()); app.use("/api", createAnalysisRouter());
app.use("/api", createMascotChatRouter()); app.use("/api", createChatbotRouter());
app.use("/api", createRecordingsRouter()); app.use("/api", createRecordingsRouter());
app.use("/api", createUiStateRouter()); app.use("/api", createUiStateRouter());
app.use("/api", createMediaRouter()); app.use("/api", createMediaRouter());
@@ -1,15 +1,15 @@
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { asyncHandler } from "../../shared/middlewares/index.js"; import { asyncHandler } from "../../shared/middlewares/index.js";
import { mascotChatService } from "./mascot-chat.service.js"; import { chatbotService } from "./chatbot.service.js";
const logger = createChildLogger("mascot-chat.controller"); const logger = createChildLogger("chatbot.controller");
interface AuthenticatedRequest extends Request { interface AuthenticatedRequest extends Request {
userId?: string; userId?: string;
} }
export const handleMascotChat = asyncHandler( export const handleChatbotChat = asyncHandler(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const { message, context } = req.body as { const { message, context } = req.body as {
message: string; message: string;
@@ -29,26 +29,26 @@ export const handleMascotChat = asyncHandler(
logger.debug( logger.debug(
{ userId, messageLength: message.length, context }, { userId, messageLength: message.length, context },
"Received mascot chat message", "Received chatbot chat message",
); );
// Process message & generate response // Process message & generate response
const response = await mascotChatService.processMessage( const response = await chatbotService.processMessage(
message, message,
context, context,
userId, userId,
); );
// Save conversation to database // Save conversation to database
await mascotChatService.saveConversation({ await chatbotService.saveConversation({
userId, userId,
userMessage: message, userMessage: message,
mascotResponse: response, botResponse: response,
context, context,
timestamp: new Date(), timestamp: new Date(),
}); });
logger.info({ userId }, "Mascot chat processed successfully"); logger.info({ userId }, "Chatbot chat processed successfully");
res.status(200).json({ res.status(200).json({
response, response,
@@ -57,12 +57,12 @@ export const handleMascotChat = asyncHandler(
}, },
); );
export const getMascotChatHistory = asyncHandler( export const getChatbotHistory = asyncHandler(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId || "anonymous"; const userId = (req as AuthenticatedRequest).userId || "anonymous";
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100); const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
const history = await mascotChatService.getChatHistory(userId, limit); const history = await chatbotService.getChatHistory(userId, limit);
res.status(200).json({ res.status(200).json({
history, history,
@@ -71,11 +71,11 @@ export const getMascotChatHistory = asyncHandler(
}, },
); );
export const clearMascotChatHistory = asyncHandler( export const clearChatbotHistory = asyncHandler(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId || "anonymous"; const userId = (req as AuthenticatedRequest).userId || "anonymous";
await mascotChatService.clearChatHistory(userId); await chatbotService.clearChatHistory(userId);
res.status(200).json({ res.status(200).json({
message: "Chat history cleared successfully", message: "Chat history cleared successfully",
@@ -1,11 +1,11 @@
import { pgMascotChatMessagesTable, pgMessagesTable } from "@bete/shared"; import { pgChatbotMessagesTable, pgMessagesTable } from "@bete/shared";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { and, desc, eq, type SQL, sql } from "drizzle-orm"; import { and, desc, eq, type SQL, sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js"; import { getDatabase } from "../../shared/database/index.js";
const logger = createChildLogger("mascot-chat.repository"); const logger = createChildLogger("chatbot.repository");
export interface MascotChatContext { export interface ChatbotContext {
messageCount?: number; messageCount?: number;
activeParticipants?: number; activeParticipants?: number;
lastActivity?: string; lastActivity?: string;
@@ -17,17 +17,17 @@ export interface MascotChatContext {
export interface SaveConversationInput { export interface SaveConversationInput {
userId: string; userId: string;
userMessage: string; userMessage: string;
mascotResponse: string; botResponse: string;
context?: MascotChatContext; context?: ChatbotContext;
timestamp: Date; timestamp: Date;
} }
export interface MascotChatHistoryRow { export interface ChatbotHistoryRow {
id: string; id: string;
user_id: string; user_id: string;
user_message: string; user_message: string;
mascot_response: string; bot_response: string;
context: MascotChatContext | null; context: ChatbotContext | null;
created_at: string; created_at: string;
} }
@@ -38,14 +38,14 @@ export interface ServerInsights {
warned: number; warned: number;
} }
export class MascotChatRepository { export class ChatbotRepository {
async saveConversation(input: SaveConversationInput): Promise<void> { async saveConversation(input: SaveConversationInput): Promise<void> {
const db = getDatabase(); const db = getDatabase();
await db.insert(pgMascotChatMessagesTable).values({ await db.insert(pgChatbotMessagesTable).values({
user_id: input.userId, user_id: input.userId,
user_message: input.userMessage, user_message: input.userMessage,
mascot_response: input.mascotResponse, bot_response: input.botResponse,
context: (input.context ?? {}) as Record<string, unknown>, context: (input.context ?? {}) as Record<string, unknown>,
created_at: input.timestamp, created_at: input.timestamp,
}); });
@@ -56,27 +56,27 @@ export class MascotChatRepository {
async getChatHistory( async getChatHistory(
userId: string, userId: string,
limit: number, limit: number,
): Promise<MascotChatHistoryRow[]> { ): Promise<ChatbotHistoryRow[]> {
const db = getDatabase(); const db = getDatabase();
const rows = await db const rows = await db
.select() .select()
.from(pgMascotChatMessagesTable) .from(pgChatbotMessagesTable)
.where(eq(pgMascotChatMessagesTable.user_id, userId)) .where(eq(pgChatbotMessagesTable.user_id, userId))
.orderBy(desc(pgMascotChatMessagesTable.created_at)) .orderBy(desc(pgChatbotMessagesTable.created_at))
.limit(limit); .limit(limit);
logger.debug({ userId, count: rows.length }, "Chat history fetched"); logger.debug({ userId, count: rows.length }, "Chat history fetched");
return rows.reverse() as unknown as MascotChatHistoryRow[]; return rows.reverse() as unknown as ChatbotHistoryRow[];
} }
async clearChatHistory(userId: string): Promise<void> { async clearChatHistory(userId: string): Promise<void> {
const db = getDatabase(); const db = getDatabase();
const deleted = await db const deleted = await db
.delete(pgMascotChatMessagesTable) .delete(pgChatbotMessagesTable)
.where(eq(pgMascotChatMessagesTable.user_id, userId)) .where(eq(pgChatbotMessagesTable.user_id, userId))
.returning({ id: pgMascotChatMessagesTable.id }); .returning({ id: pgChatbotMessagesTable.id });
logger.info( logger.info(
{ userId, deletedRows: deleted.length }, { userId, deletedRows: deleted.length },
@@ -135,4 +135,4 @@ export class MascotChatRepository {
} }
} }
export const mascotChatRepository = new MascotChatRepository(); export const chatbotRepository = new ChatbotRepository();
@@ -0,0 +1,22 @@
import express, { type Router } from "express";
import { validateBody } from "../../shared/middlewares/index.js";
import {
clearChatbotHistory,
getChatbotHistory,
handleChatbotChat,
} from "./chatbot.controller.js";
import { chatRequestSchema } from "./chatbot.schema.js";
export function createChatbotRouter(): Router {
const router = express.Router();
router.post(
"/chat",
validateBody(chatRequestSchema),
handleChatbotChat,
);
router.get("/chat/history", getChatbotHistory);
router.delete("/chat/history", clearChatbotHistory);
return router;
}
@@ -1,18 +1,18 @@
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { config } from "../../shared/config/index.js"; import { config } from "../../shared/config/index.js";
import type { import type {
MascotChatContext, ChatbotContext,
MascotChatHistoryRow, ChatbotHistoryRow,
SaveConversationInput, SaveConversationInput,
} from "./mascot-chat.repository.js"; } from "./chatbot.repository.js";
import { mascotChatRepository } from "./mascot-chat.repository.js"; import { chatbotRepository } from "./chatbot.repository.js";
const logger = createChildLogger("mascot-chat.service"); const logger = createChildLogger("chatbot.service");
class MascotChatService { class ChatbotService {
async processMessage( async processMessage(
message: string, message: string,
context: MascotChatContext | undefined, context: ChatbotContext | undefined,
userId: string, userId: string,
): Promise<string> { ): Promise<string> {
logger.info( logger.info(
@@ -20,7 +20,7 @@ class MascotChatService {
"processMessage called", "processMessage called",
); );
const recentContext = await this.getRecentConversationContext(userId); const recentContext = await this.getRecentConversationContext(userId);
const serverInsights = await mascotChatRepository.getServerInsights( const serverInsights = await chatbotRepository.getServerInsights(
context?.guildId, context?.guildId,
context?.channelId, context?.channelId,
); );
@@ -39,29 +39,29 @@ class MascotChatService {
async saveConversation(input: SaveConversationInput): Promise<void> { async saveConversation(input: SaveConversationInput): Promise<void> {
logger.info({ userId: input.userId }, "saveConversation called"); logger.info({ userId: input.userId }, "saveConversation called");
await mascotChatRepository.saveConversation(input); await chatbotRepository.saveConversation(input);
} }
async getChatHistory( async getChatHistory(
userId: string, userId: string,
limit: number, limit: number,
): Promise<MascotChatHistoryRow[]> { ): Promise<ChatbotHistoryRow[]> {
logger.debug({ userId, limit }, "getChatHistory called"); logger.debug({ userId, limit }, "getChatHistory called");
return mascotChatRepository.getChatHistory(userId, limit); return chatbotRepository.getChatHistory(userId, limit);
} }
async clearChatHistory(userId: string): Promise<void> { async clearChatHistory(userId: string): Promise<void> {
logger.info({ userId }, "clearChatHistory called"); logger.info({ userId }, "clearChatHistory called");
await mascotChatRepository.clearChatHistory(userId); await chatbotRepository.clearChatHistory(userId);
} }
private async getRecentConversationContext( private async getRecentConversationContext(
userId: string, userId: string,
): Promise<string[]> { ): Promise<string[]> {
const history = await mascotChatRepository.getChatHistory(userId, 3); const history = await chatbotRepository.getChatHistory(userId, 3);
return history.flatMap((row) => [ return history.flatMap((row) => [
`User: ${row.user_message}`, `User: ${row.user_message}`,
`Mascot: ${row.mascot_response}`, `Bot: ${row.bot_response}`,
]); ]);
} }
@@ -71,7 +71,7 @@ class MascotChatService {
flagged: number; flagged: number;
warned: number; warned: number;
}): string { }): string {
return `Kamu lagi ngobrol sama mascot Discord Watcher — temen ngobrol yang tau keadaan server. return `Kamu lagi ngobrol sama chatbot Discord Watcher — temen ngobrol yang tau keadaan server.
Data server saat ini: Data server saat ini:
- Pesan: ${insights.total_messages} - Pesan: ${insights.total_messages}
@@ -92,7 +92,7 @@ Gaya ngobrol:
private buildHistoryMessages( private buildHistoryMessages(
recentContext: string[], recentContext: string[],
): Array<{ role: "user" | "assistant"; content: string }> { ): Array<{ role: "user" | "assistant"; content: string }> {
// recentContext is alternating User/Mascot messages // recentContext is alternating User/Bot messages
return recentContext.map((text) => { return recentContext.map((text) => {
if (text.startsWith("User: ")) { if (text.startsWith("User: ")) {
return { role: "user" as const, content: text.slice(6) }; return { role: "user" as const, content: text.slice(6) };
@@ -178,4 +178,4 @@ Gaya ngobrol:
} }
} }
export const mascotChatService = new MascotChatService(); export const chatbotService = new ChatbotService();
@@ -0,0 +1 @@
export { createChatbotRouter } from "./chatbot.routes.js";
@@ -1 +0,0 @@
export { createMascotChatRouter } from "./mascot-chat.routes.js";
@@ -1,22 +0,0 @@
import express, { type Router } from "express";
import { validateBody } from "../../shared/middlewares/index.js";
import {
clearMascotChatHistory,
getMascotChatHistory,
handleMascotChat,
} from "./mascot-chat.controller.js";
import { chatRequestSchema } from "./mascot-chat.schema.js";
export function createMascotChatRouter(): Router {
const router = express.Router();
router.post(
"/mascot/chat",
validateBody(chatRequestSchema),
handleMascotChat,
);
router.get("/mascot/chat/history", getMascotChatHistory);
router.delete("/mascot/chat/history", clearMascotChatHistory);
return router;
}
@@ -1,5 +1,5 @@
import { import {
pgMascotChatMessagesTable, pgChatbotMessagesTable,
pgMuxerJobsTable, pgMuxerJobsTable,
pgRetentionPoliciesTable, pgRetentionPoliciesTable,
pgUIStateTable, pgUIStateTable,
@@ -7,7 +7,7 @@ import {
// Re-export shared tables // Re-export shared tables
export { export {
pgMascotChatMessagesTable, pgChatbotMessagesTable,
pgMuxerJobsTable, pgMuxerJobsTable,
pgRetentionPoliciesTable, pgRetentionPoliciesTable,
pgUIStateTable, pgUIStateTable,
@@ -15,7 +15,7 @@ export {
export const muxerJobsTable = pgMuxerJobsTable; export const muxerJobsTable = pgMuxerJobsTable;
export const uiStateTable = pgUIStateTable; export const uiStateTable = pgUIStateTable;
export const retentionPoliciesTable = pgRetentionPoliciesTable; export const retentionPoliciesTable = pgRetentionPoliciesTable;
export const mascotChatMessagesTable = pgMascotChatMessagesTable; export const chatbotMessagesTable = pgChatbotMessagesTable;
// Types // Types
export type MuxerJob = typeof muxerJobsTable.$inferSelect; export type MuxerJob = typeof muxerJobsTable.$inferSelect;
@@ -24,6 +24,6 @@ export type UIState = typeof uiStateTable.$inferSelect;
export type UIStateInsert = typeof uiStateTable.$inferInsert; export type UIStateInsert = typeof uiStateTable.$inferInsert;
export type RetentionPolicy = typeof retentionPoliciesTable.$inferSelect; export type RetentionPolicy = typeof retentionPoliciesTable.$inferSelect;
export type RetentionPolicyInsert = typeof retentionPoliciesTable.$inferInsert; export type RetentionPolicyInsert = typeof retentionPoliciesTable.$inferInsert;
export type MascotChatMessage = typeof mascotChatMessagesTable.$inferSelect; export type ChatbotMessage = typeof chatbotMessagesTable.$inferSelect;
export type MascotChatMessageInsert = export type ChatbotMessageInsert =
typeof mascotChatMessagesTable.$inferInsert; typeof chatbotMessagesTable.$inferInsert;
+1 -1
View File
@@ -25,7 +25,7 @@ src/
│ ├── messages/ # Message feed, search, review, detail modal │ ├── messages/ # Message feed, search, review, detail modal
│ ├── live/ # Voice connection, music player, recordings │ ├── live/ # Voice connection, music player, recordings
│ ├── dashboard/ # Stats, users, channels overview │ ├── dashboard/ # Stats, users, channels overview
│ └── mascot/ # AI chatbot │ └── chatbot/ # AI chatbot
├── lib/ ├── lib/
│ ├── api/ # Fetch-based API client (all BE endpoints) │ ├── api/ # Fetch-based API client (all BE endpoints)
│ ├── ws/ # WebSocket client + React context │ ├── ws/ # WebSocket client + React context
@@ -5,8 +5,8 @@ import { Suspense, useEffect, useState } from "react";
import { TopNav } from "@/components/layout/top-nav"; import { TopNav } from "@/components/layout/top-nav";
import { MobileNav } from "@/components/layout/mobile-nav"; import { MobileNav } from "@/components/layout/mobile-nav";
import { WsProvider, useWebSocket } from "@/lib/ws/context"; import { WsProvider, useWebSocket } from "@/lib/ws/context";
import { MascotProvider, useMascot } from "@/components/mascot/mascot-context"; import { ChatbotProvider, useChatbot } from "@/components/chatbot/chatbot-context";
import { MascotContainer } from "@/components/mascot/mascot-container"; import { ChatbotContainer } from "@/components/chatbot/chatbot-container";
import { MiniPlayer } from "@/components/media/mini-player"; import { MiniPlayer } from "@/components/media/mini-player";
import { MediaPlayerProvider } from "@/lib/hooks/use-media-player"; import { MediaPlayerProvider } from "@/lib/hooks/use-media-player";
import { HiddenSidebar } from "@/components/layout/hidden-sidebar"; import { HiddenSidebar } from "@/components/layout/hidden-sidebar";
@@ -21,9 +21,9 @@ const queryClient = new QueryClient({
}, },
}); });
function MascotExpressionSync() { function ChatbotExpressionSync() {
const ws = useWebSocket(); const ws = useWebSocket();
const { setExpression } = useMascot(); const { setExpression } = useChatbot();
useEffect(() => { useEffect(() => {
const unsub1 = ws.on("message_created", (data: any) => { const unsub1 = ws.on("message_created", (data: any) => {
@@ -57,8 +57,8 @@ export default function DashboardLayout({
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<WsProvider> <WsProvider>
<MediaPlayerProvider> <MediaPlayerProvider>
<MascotProvider> <ChatbotProvider>
<MascotExpressionSync /> <ChatbotExpressionSync />
<div className="min-h-screen bg-canvas"> <div className="min-h-screen bg-canvas">
<TopNav /> <TopNav />
<HiddenSidebar guildId={guildId} onGuildChange={(g) => setGuildId(g ?? "")} /> <HiddenSidebar guildId={guildId} onGuildChange={(g) => setGuildId(g ?? "")} />
@@ -80,9 +80,9 @@ export default function DashboardLayout({
<MobileNav /> <MobileNav />
<MiniPlayer /> <MiniPlayer />
<MascotContainer /> <ChatbotContainer />
</div> </div>
</MascotProvider> </ChatbotProvider>
</MediaPlayerProvider> </MediaPlayerProvider>
</WsProvider> </WsProvider>
</QueryClientProvider> </QueryClientProvider>
@@ -2,14 +2,14 @@
import { useRef, useEffect } from "react"; import { useRef, useEffect } from "react";
import { Send } from "lucide-react"; import { Send } from "lucide-react";
import { useMascot } from "./mascot-context"; import { useChatbot } from "./chatbot-context";
interface ChatPanelProps { interface ChatPanelProps {
inputRef?: React.RefObject<HTMLInputElement | null>; inputRef?: React.RefObject<HTMLInputElement | null>;
} }
export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) { export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
const { messages, sendMessage, isTyping } = useMascot(); const { messages, sendMessage, isTyping } = useChatbot();
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const internalInputRef = useRef<HTMLInputElement>(null); const internalInputRef = useRef<HTMLInputElement>(null);
const inputRef = externalInputRef ?? internalInputRef; const inputRef = externalInputRef ?? internalInputRef;
@@ -35,7 +35,7 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
<div ref={listRef} className="flex-1 overflow-y-auto px-2 py-1 space-y-1"> <div ref={listRef} className="flex-1 overflow-y-auto px-2 py-1 space-y-1">
{messages.length === 0 && ( {messages.length === 0 && (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<p className="text-[10px] text-text-secondary/40">Ask mascot anything</p> <p className="text-[10px] text-text-secondary/40">Ask chatbot anything</p>
</div> </div>
)} )}
{messages.slice(-8).map((msg, i) => ( {messages.slice(-8).map((msg, i) => (
@@ -72,7 +72,7 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
placeholder="Ask mascot..." placeholder="Ask chatbot..."
className="flex-1 bg-transparent text-[10px] text-text-primary placeholder-text-secondary/30 outline-none" className="flex-1 bg-transparent text-[10px] text-text-primary placeholder-text-secondary/30 outline-none"
disabled={isTyping} disabled={isTyping}
/> />
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useMascot } from "./mascot-context"; import { useChatbot } from "./chatbot-context";
/** /**
* Live2D Cubism WebGL canvas. * Live2D Cubism WebGL canvas.
@@ -10,15 +10,15 @@ import { useMascot } from "./mascot-context";
* Integration requires: * Integration requires:
* 1. Live2D Cubism SDK for Web (npm: @live2d/cubism) * 1. Live2D Cubism SDK for Web (npm: @live2d/cubism)
* 2. Model files: .model3.json, .moc3, .physics3.json, textures * 2. Model files: .model3.json, .moc3, .physics3.json, textures
* 3. Place model files in public/mascot/ * 3. Place model files in public/chatbot/
* *
* The current implementation shows a placeholder character. * The current implementation shows a placeholder character.
* Replace with actual Cubism SDK integration when model files are available. * Replace with actual Cubism SDK integration when model files are available.
*/ */
export function MascotCanvas() { export function ChatbotCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const { expression } = useMascot(); const { expression } = useChatbot();
// Placeholder: draw a simple avatar face that responds to expression // Placeholder: draw a simple avatar face that responds to expression
useEffect(() => { useEffect(() => {
@@ -2,12 +2,12 @@
import { useRef, useState, useCallback, useEffect } from "react"; import { useRef, useState, useCallback, useEffect } from "react";
import { Bot, MessageCircle, Minimize2 } from "lucide-react"; import { Bot, MessageCircle, Minimize2 } from "lucide-react";
import { useMascot } from "./mascot-context"; import { useChatbot } from "./chatbot-context";
import { MascotCanvas } from "./mascot-canvas"; import { ChatbotCanvas } from "./chatbot-canvas";
import { ChatPanel } from "./chat-panel"; import { ChatPanel } from "./chat-panel";
export function MascotContainer() { export function ChatbotContainer() {
const { minimized, setMinimized, chatOpen, setChatOpen } = useMascot(); const { minimized, setMinimized, chatOpen, setChatOpen } = useChatbot();
const [position, setPosition] = useState({ x: 0, y: 0 }); const [position, setPosition] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
@@ -42,7 +42,7 @@ export function MascotContainer() {
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp} onMouseLeave={handleMouseUp}
> >
{/* Main mascot bubble */} {/* Main chatbot bubble */}
<div <div
className={`glass-intense rounded-2xl overflow-hidden transition-all duration-200 ${ className={`glass-intense rounded-2xl overflow-hidden transition-all duration-200 ${
minimized ? "w-14 h-14 cursor-pointer" : "w-[220px]" minimized ? "w-14 h-14 cursor-pointer" : "w-[220px]"
@@ -55,7 +55,7 @@ export function MascotContainer() {
onClick={() => setMinimized(false)} onClick={() => setMinimized(false)}
className="w-full h-full flex items-center justify-center" className="w-full h-full flex items-center justify-center"
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
aria-label="Open mascot" aria-label="Open chatbot"
> >
<Bot className="size-6 text-primary" /> <Bot className="size-6 text-primary" />
</button> </button>
@@ -67,7 +67,7 @@ export function MascotContainer() {
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
> >
<span className="text-[10px] font-semibold text-text-secondary tracking-wide uppercase"> <span className="text-[10px] font-semibold text-text-secondary tracking-wide uppercase">
Mascot Chatbot
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
@@ -82,7 +82,7 @@ export function MascotContainer() {
type="button" type="button"
onClick={() => setMinimized(true)} onClick={() => setMinimized(true)}
className="size-5 flex items-center justify-center rounded hover:bg-glass-bg transition-colors" className="size-5 flex items-center justify-center rounded hover:bg-glass-bg transition-colors"
aria-label="Minimize mascot" aria-label="Minimize chatbot"
> >
{minimized ? ( {minimized ? (
<Bot className="size-3 text-text-secondary/60 hover:text-text-primary" /> <Bot className="size-3 text-text-secondary/60 hover:text-text-primary" />
@@ -95,7 +95,7 @@ export function MascotContainer() {
{/* Canvas area */} {/* Canvas area */}
<div className="h-[140px] flex items-center justify-center"> <div className="h-[140px] flex items-center justify-center">
<MascotCanvas /> <ChatbotCanvas />
</div> </div>
{/* Chat panel (expandable) */} {/* Chat panel (expandable) */}
@@ -12,18 +12,18 @@ import {
import { chatbotApi } from "@/lib/api"; import { chatbotApi } from "@/lib/api";
import type { ChatHistoryMessage } from "@/lib/types"; import type { ChatHistoryMessage } from "@/lib/types";
export type MascotExpression = "idle" | "listening" | "surprise" | "happy" | "sad" | "talking"; export type ChatbotExpression = "idle" | "listening" | "surprise" | "happy" | "sad" | "talking";
interface MascotMessage { interface ChatbotMessage {
role: "user" | "assistant"; role: "user" | "assistant";
content: string; content: string;
timestamp: string; timestamp: string;
} }
interface MascotContextValue { interface ChatbotContextValue {
/** Expression the mascot avatar should display */ /** Expression the chatbot avatar should display */
expression: MascotExpression; expression: ChatbotExpression;
setExpression: (expr: MascotExpression) => void; setExpression: (expr: ChatbotExpression) => void;
/** Whether the enlarged bubble is minimized to a small icon */ /** Whether the enlarged bubble is minimized to a small icon */
minimized: boolean; minimized: boolean;
@@ -42,19 +42,19 @@ interface MascotContextValue {
toggle: () => void; toggle: () => void;
/** Chat messages with real API backend */ /** Chat messages with real API backend */
messages: MascotMessage[]; messages: ChatbotMessage[];
sendMessage: (content: string) => Promise<void>; sendMessage: (content: string) => Promise<void>;
clearMessages: () => Promise<void>; clearMessages: () => Promise<void>;
isTyping: boolean; isTyping: boolean;
} }
const MascotContext = createContext<MascotContextValue | null>(null); const ChatbotContext = createContext<ChatbotContextValue | null>(null);
export function MascotProvider({ children }: { children: ReactNode }) { export function ChatbotProvider({ children }: { children: ReactNode }) {
const [expression, setExpression] = useState<MascotExpression>("idle"); const [expression, setExpression] = useState<ChatbotExpression>("idle");
const [minimized, setMinimized] = useState(true); const [minimized, setMinimized] = useState(true);
const [chatOpen, setChatOpen] = useState(false); const [chatOpen, setChatOpen] = useState(false);
const [messages, setMessages] = useState<MascotMessage[]>([]); const [messages, setMessages] = useState<ChatbotMessage[]>([]);
const [isTyping, setIsTyping] = useState(false); const [isTyping, setIsTyping] = useState(false);
const historyFetched = useRef(false); const historyFetched = useRef(false);
@@ -89,7 +89,7 @@ export function MascotProvider({ children }: { children: ReactNode }) {
const sendMessage = useCallback(async (content: string) => { const sendMessage = useCallback(async (content: string) => {
if (!content.trim()) return; if (!content.trim()) return;
const userMsg: MascotMessage = { const userMsg: ChatbotMessage = {
role: "user", role: "user",
content: content.trim(), content: content.trim(),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -100,7 +100,7 @@ export function MascotProvider({ children }: { children: ReactNode }) {
try { try {
const res = await chatbotApi.send(content.trim()); const res = await chatbotApi.send(content.trim());
const botMsg: MascotMessage = { const botMsg: ChatbotMessage = {
role: "assistant", role: "assistant",
content: res.response, content: res.response,
timestamp: res.timestamp ?? new Date().toISOString(), timestamp: res.timestamp ?? new Date().toISOString(),
@@ -108,7 +108,7 @@ export function MascotProvider({ children }: { children: ReactNode }) {
setMessages((prev) => [...prev, botMsg]); setMessages((prev) => [...prev, botMsg]);
setExpression("happy"); setExpression("happy");
} catch { } catch {
const errorMsg: MascotMessage = { const errorMsg: ChatbotMessage = {
role: "assistant", role: "assistant",
content: "Sorry, I couldn't process that request. Please try again.", content: "Sorry, I couldn't process that request. Please try again.",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -130,7 +130,7 @@ export function MascotProvider({ children }: { children: ReactNode }) {
}, []); }, []);
return ( return (
<MascotContext.Provider <ChatbotContext.Provider
value={{ value={{
expression, expression,
setExpression, setExpression,
@@ -148,14 +148,14 @@ export function MascotProvider({ children }: { children: ReactNode }) {
}} }}
> >
{children} {children}
</MascotContext.Provider> </ChatbotContext.Provider>
); );
} }
export function useMascot(): MascotContextValue { export function useChatbot(): ChatbotContextValue {
const ctx = useContext(MascotContext); const ctx = useContext(ChatbotContext);
if (!ctx) { if (!ctx) {
throw new Error("useMascot must be used within a MascotProvider"); throw new Error("useChatbot must be used within a ChatbotProvider");
} }
return ctx; return ctx;
} }
@@ -0,0 +1,4 @@
export { ChatbotProvider, useChatbot } from "./chatbot-context";
export { ChatbotContainer } from "./chatbot-container";
export { ChatbotCanvas } from "./chatbot-canvas";
export { ChatPanel } from "./chat-panel";
@@ -1,93 +0,0 @@
"use client";
import { ArrowLeft, Clock, Hash, Sparkles } from "lucide-react";
import { DetailStat, ErrorState, LoadingSkeleton } from "@/components/shared";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { useChannelDetail } from "@/hooks";
export function ChannelDetailSection({
channelId,
onBack,
}: {
channelId: string;
onBack: () => void;
}) {
const { data: channel, isLoading } = useChannelDetail(channelId);
if (isLoading) return <LoadingSkeleton count={1} height="h-64" />;
if (!channel) return <ErrorState message="Channel not found." />;
return (
<div className="space-y-5 animate-fade-in-up">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" /> Back
</Button>
<Card>
<CardContent className="p-6 space-y-5">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2">
<Hash className="size-5 text-muted-foreground" />
{channel.channel_name ?? channel.channel_id.slice(0, 8)}
</h2>
<p className="text-xs text-muted-foreground font-mono">
{channel.channel_id}
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<DetailStat label="Messages" value={channel.total_messages} />
<DetailStat
label="Flagged"
value={channel.flagged_count}
variant="danger"
/>
<DetailStat
label="Clean"
value={channel.clean_count}
variant="success"
/>
</div>
{channel.culture_summary && (
<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 uppercase tracking-wider">
Channel Culture
</p>
</div>
<p className="text-sm leading-relaxed italic">
&ldquo;{channel.culture_summary}&rdquo;
</p>
</div>
)}
{channel.recent_messages.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Clock className="size-4 text-muted-foreground" /> Recent
Messages
</h3>
<div className="space-y-2">
{channel.recent_messages.slice(0, 5).map((msg) => (
<div
key={msg.id}
className="rounded-lg border border-border/50 bg-muted/20 p-3 text-sm"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium">
{msg.username}
</span>
<span className="text-xs text-muted-foreground">
{new Date(msg.created_at).toLocaleString()}
</span>
</div>
<p className="text-sm">{msg.content}</p>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -1,78 +0,0 @@
"use client";
import { ChevronRight, Hash, Search } from "lucide-react";
import { useState } from "react";
import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { useChannels } from "@/hooks";
export function ChannelsSection({
guildId,
onSelect,
}: {
guildId: string;
onSelect: (id: string) => void;
}) {
const [search, setSearch] = useState("");
const {
data: channels,
isLoading,
refetch,
} = useChannels(guildId, search || undefined);
return (
<div className="space-y-4 animate-fade-in-up">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search channels…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
{isLoading ? (
<LoadingSkeleton count={6} height="h-20" />
) : !channels || channels.length === 0 ? (
<EmptyState icon={Hash} title="No channels found." />
) : (
<div className="space-y-2">
{channels.map((ch) => (
<Card
key={ch.channel_id}
className="cursor-pointer hover:bg-accent/5 transition-colors"
onClick={() => onSelect(ch.channel_id)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Hash className="size-3.5 text-muted-foreground shrink-0" />
<p className="text-sm font-medium truncate">
{ch.channel_name ?? ch.channel_id.slice(0, 8)}
</p>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{ch.total_messages} messages
{ch.flagged_count > 0
? ` · ${ch.flagged_count} flagged`
: ""}
</p>
</div>
<ChevronRight className="size-4 text-muted-foreground shrink-0 ml-2" />
</div>
{ch.culture_summary && (
<p className="text-xs text-muted-foreground/70 mt-2 italic line-clamp-2 border-t border-border/50 pt-2">
&ldquo;{ch.culture_summary}&rdquo;
</p>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
@@ -1,5 +0,0 @@
export { ChannelDetailSection } from "./channel-detail-section";
export { ChannelsSection } from "./channels-section";
export { StatsSection } from "./stats-section";
export { UserDetailSection } from "./user-detail-section";
export { UsersSection } from "./users-section";
@@ -1,145 +0,0 @@
"use client";
import {
AlertCircle,
Clock,
Hash,
Shield,
Sparkles,
Users,
} from "lucide-react";
import { ErrorState, LoadingSkeleton, StatCard } from "@/components/shared";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { useStats } from "@/hooks";
import { formatNumber } from "@/lib/format";
export function StatsSection() {
const { data: stats, isLoading, error, refetch } = useStats();
if (error) return <ErrorState message={error.message} onRetry={refetch} />;
if (isLoading || !stats)
return (
<div className="space-y-5 animate-fade-in-up">
<LoadingSkeleton count={8} height="h-28" columns={4} />
</div>
);
return (
<div className="space-y-5 animate-fade-in-up">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard
label="Total Messages"
value={stats.total_messages}
icon={Hash}
/>
<StatCard label="Today" value={stats.today_messages} icon={Clock} />
<StatCard label="Users" value={stats.total_users} icon={Users} />
<StatCard
label="Active 24h"
value={stats.active_users_24h}
icon={Sparkles}
/>
<StatCard
label="Flagged"
value={stats.total_flagged}
icon={AlertCircle}
variant="danger"
/>
<StatCard
label="Clean"
value={stats.total_clean}
icon={Shield}
variant="success"
/>
<StatCard
label="Voice Recordings"
value={stats.total_voice_recordings}
icon={Hash}
/>
<StatCard
label="AI Profiles"
value={stats.total_profiles}
icon={Sparkles}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Hash className="size-4 text-muted-foreground" /> Top Channels
</CardTitle>
</CardHeader>
<CardContent>
{stats.top_channels.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
No channel data yet.
</p>
) : (
<div className="space-y-2">
{stats.top_channels.map((ch) => {
const max = stats.top_channels[0].message_count;
const pct = max > 0 ? (ch.message_count / max) * 100 : 0;
return (
<div key={ch.channel_id} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="truncate font-medium">
#{ch.channel_name ?? ch.channel_id.slice(0, 8)}
</span>
<span className="text-muted-foreground tabular-nums">
{formatNumber(ch.message_count)}
</span>
</div>
<Progress value={pct} className="h-1.5" />
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Shield className="size-4 text-muted-foreground" /> Moderation
Queue
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-3">
{[
{
label: "Pending",
value: stats.moderation_overview.pending,
cls: "bg-muted/50",
},
{
label: "Processing",
value: stats.moderation_overview.processing,
cls: "bg-yellow-500/10 text-yellow-500",
},
{
label: "Errors",
value: stats.moderation_overview.error,
cls: "bg-destructive/10 text-destructive",
},
].map(({ label, value, cls }) => (
<div
key={label}
className={`rounded-lg p-3 text-center space-y-1.5 ${cls}`}
>
<div
className={`text-2xl font-bold tabular-nums ${cls.includes("yellow") ? "text-yellow-500" : cls.includes("destructive") ? "text-destructive" : ""}`}
>
{value}
</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -1,106 +0,0 @@
"use client";
import { ArrowLeft, Clock, Sparkles } from "lucide-react";
import Image from "next/image";
import { DetailStat, ErrorState, LoadingSkeleton } from "@/components/shared";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { useUserDetail } from "@/hooks";
export function UserDetailSection({
userId,
onBack,
}: {
userId: string;
onBack: () => void;
}) {
const { data: user, isLoading } = useUserDetail(userId);
if (isLoading) return <LoadingSkeleton count={1} height="h-64" />;
if (!user) return <ErrorState message="User not found." />;
return (
<div className="space-y-5 animate-fade-in-up">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" /> Back
</Button>
<Card>
<CardContent className="p-6 space-y-5">
<div className="flex items-center gap-4">
<div className="size-14 shrink-0 rounded-full bg-muted flex items-center justify-center text-xl font-medium overflow-hidden ring-2 ring-border">
{user.avatar_url ? (
<Image
src={user.avatar_url}
alt=""
width={56}
height={56}
className="size-full object-cover"
/>
) : (
(user.username ?? "?").charAt(0).toUpperCase()
)}
</div>
<div className="min-w-0">
<h2 className="text-lg font-semibold">
{user.username ?? "Unknown"}
</h2>
<p className="text-xs text-muted-foreground font-mono">
{user.user_id}
</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<DetailStat label="Messages" value={user.total_messages} />
<DetailStat
label="Flagged"
value={user.flagged_count}
variant="danger"
/>
<DetailStat
label="Clean Streak"
value={user.clean_message_streak ?? 0}
/>
<DetailStat
label="Trust Score"
value={user.trust_score ?? 0}
suffix="%"
/>
</div>
{user.profile_summary && (
<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 uppercase tracking-wider">
AI Profile
</p>
</div>
<p className="text-sm leading-relaxed">{user.profile_summary}</p>
</div>
)}
{user.recent_messages.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Clock className="size-4 text-muted-foreground" /> Recent
Messages
</h3>
<div className="space-y-2 max-h-80 overflow-y-auto">
{user.recent_messages.slice(0, 5).map((msg) => (
<div
key={msg.id}
className="rounded-lg border border-border/50 bg-muted/20 p-3 text-sm"
>
<p className="text-xs text-muted-foreground mb-1 flex items-center gap-2">
<Clock className="size-3" />
{new Date(msg.created_at).toLocaleString()}
</p>
<p className="text-sm">{msg.content}</p>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -1,80 +0,0 @@
"use client";
import { ChevronRight, Search, Users } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { useUsers } from "@/hooks";
export function UsersSection({ onSelect }: { onSelect: (id: string) => void }) {
const [search, setSearch] = useState("");
const { data: users, isLoading } = useUsers(search || undefined);
return (
<div className="space-y-4 animate-fade-in-up">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search users…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
{isLoading ? (
<LoadingSkeleton count={6} height="h-20" columns={2} />
) : !users || users.length === 0 ? (
<EmptyState icon={Users} title="No users found." />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{users.map((u) => (
<Card
key={u.user_id}
className="cursor-pointer hover:bg-accent/5 transition-colors"
onClick={() => onSelect(u.user_id)}
>
<CardContent className="p-3">
<div className="flex items-center gap-3">
<div className="size-10 shrink-0 rounded-full bg-muted flex items-center justify-center text-sm font-medium overflow-hidden ring-1 ring-border">
{u.avatar_url ? (
<Image
src={u.avatar_url}
alt=""
width={40}
height={40}
className="size-full object-cover"
/>
) : (
(u.username ?? "?").charAt(0).toUpperCase()
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{u.username ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground flex items-center gap-2">
<span>{u.total_messages} messages</span>
{u.flagged_count > 0 && (
<Badge
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{u.flagged_count} flagged
</Badge>
)}
</p>
</div>
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
@@ -1,4 +0,0 @@
export { MascotProvider, useMascot } from "./mascot-context";
export { MascotContainer } from "./mascot-container";
export { MascotCanvas } from "./mascot-canvas";
export { ChatPanel } from "./chat-panel";
@@ -1,62 +0,0 @@
"use client";
import { ImageIcon } from "lucide-react";
import { Card } from "@/components/ui/card";
import type { MessageRecord } from "@/lib/types";
import { extractFirstImage } from "./message-card";
export function ImagesGrid({
images,
onSelect,
}: {
images: MessageRecord[];
onSelect: (id: string) => void;
}) {
if (!images || images.length === 0) {
return (
<div className="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 = extractFirstImage(msg.metadata);
return (
<Card
key={msg.id}
className="group relative overflow-hidden cursor-pointer"
onClick={() => onSelect(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>
);
}
@@ -1,165 +0,0 @@
"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";
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: {
id: string;
filename: string;
type: string;
size: number;
uploaded_url?: string | null;
discord_url?: string | null;
}[];
}) {
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>
);
}
@@ -1,39 +0,0 @@
"use client";
import { Flag } from "lucide-react";
import type { MessageRecord } from "@/lib/types";
import { MessageCard } from "./message-card";
export function ReviewList({
reviews,
onSelect,
onReanalyze,
}: {
reviews: MessageRecord[];
onSelect: (id: string) => void;
onReanalyze: (id: string) => void;
}) {
if (!reviews || 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={onSelect}
onReanalyze={onReanalyze}
/>
))}
</div>
);
}
@@ -1,94 +0,0 @@
"use client";
import { Download, Headphones, Trash2 } from "lucide-react";
import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
useDeleteRecording,
useRecordings,
useRecordingsWsSync,
} from "@/hooks";
import { formatBytes } from "@/lib/format";
import type { WsHook } from "@/lib/ws-hook";
interface RecordingListProps {
ws: WsHook;
}
export function RecordingList({ ws }: RecordingListProps) {
const { data: recordings, isLoading } = useRecordings();
const deleteMut = useDeleteRecording();
useRecordingsWsSync(ws);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Headphones className="size-4 text-primary" />
Voice Recordings
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<LoadingSkeleton count={5} height="h-16" />
) : !recordings || recordings.length === 0 ? (
<EmptyState icon={Headphones} title="No recordings yet." />
) : (
<div className="space-y-2">
{recordings.map((rec) => (
<div
key={rec.id}
className="flex items-center gap-3 rounded-lg border border-border/50 p-3 hover:bg-muted/30 transition-colors"
>
<Avatar className="size-8">
<AvatarImage src={rec.avatar_url ?? undefined} />
<AvatarFallback>
{(rec.username ?? "?").charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{rec.username}</p>
<p className="text-xs text-muted-foreground">
{rec.channel_name ?? rec.channel_id ?? "Unknown channel"} {" "}
{new Date(rec.created_at).toLocaleString()}
</p>
</div>
<Badge
variant="outline"
className="text-[10px] font-mono shrink-0"
>
{formatBytes(rec.size_bytes)}
</Badge>
{rec.download_url && (
<Button
variant="ghost"
size="icon"
onClick={() => {
if (rec.download_url)
window.open(rec.download_url, "_blank");
}}
>
<Download className="size-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => deleteMut.mutate(rec.id)}
className="hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
@@ -1,42 +0,0 @@
import { Card, CardContent } from "@/components/ui/card";
import { formatNumber } from "@/lib/format";
import { cn } from "@/lib/utils";
interface DetailStatProps {
label: string;
value: number;
variant?: "default" | "danger" | "success";
suffix?: string;
}
const valueColor = {
default: "",
danger: "text-red-400",
success: "text-emerald-400",
};
/**
* Small stat label used inside detail views.
*/
export function DetailStat({
label,
value,
variant = "default",
suffix,
}: DetailStatProps) {
return (
<Card className="bg-gradient-to-br from-cyan-500/5 to-transparent border-cyan-500/10">
<CardContent className="p-3">
<p className="text-xs text-muted-foreground/70 tracking-wide">
{label}
</p>
<p
className={cn("text-lg font-bold tabular-nums", valueColor[variant])}
>
{formatNumber(value)}
{suffix}
</p>
</CardContent>
</Card>
);
}
@@ -1,78 +0,0 @@
import type { LucideIcon } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { formatNumber } from "@/lib/format";
import { cn } from "@/lib/utils";
interface StatCardProps {
label: string;
value: number;
icon: LucideIcon;
variant?: "default" | "danger" | "success" | "warning";
}
const variantStyles = {
default: "from-cyan-500/10 to-teal-500/5 border-cyan-500/20",
danger: "from-red-500/10 to-rose-500/5 border-red-500/20",
success: "from-emerald-500/10 to-green-500/5 border-emerald-500/20",
warning: "from-amber-500/10 to-yellow-500/5 border-amber-500/20",
};
const iconBg = {
default: "bg-cyan-500/15 text-cyan-400",
danger: "bg-red-500/15 text-red-400",
success: "bg-emerald-500/15 text-emerald-400",
warning: "bg-amber-500/15 text-amber-400",
};
const valueColor = {
default: "",
danger: "text-red-400",
success: "text-emerald-400",
warning: "text-amber-400",
};
/**
* Metric card used across dashboard and landing pages.
*/
export function StatCard({
label,
value,
icon: Icon,
variant = "default",
}: StatCardProps) {
return (
<Card
className={cn(
"border bg-gradient-to-br backdrop-blur-sm",
variantStyles[variant],
)}
>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground/80 tracking-wide">
{label}
</p>
<p
className={cn(
"text-2xl font-bold tabular-nums tracking-tight",
valueColor[variant],
)}
>
{formatNumber(value)}
</p>
</div>
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg",
iconBg[variant],
)}
>
<Icon className="size-4" />
</div>
</div>
</CardContent>
</Card>
);
}
+3 -3
View File
@@ -3,9 +3,9 @@ import { api } from "./client";
export const chatbotApi = { export const chatbotApi = {
send: (message: string) => send: (message: string) =>
api.post<ChatbotResponse>("/api/mascot/chat", { message }), api.post<ChatbotResponse>("/api/chat", { message }),
getHistory: () => api.get<ChatHistoryMessage[]>("/api/mascot/chat/history"), getHistory: () => api.get<ChatHistoryMessage[]>("/api/chat/history"),
clearHistory: () => api.delete<{ ok: boolean }>("/api/mascot/chat/history"), clearHistory: () => api.delete<{ ok: boolean }>("/api/chat/history"),
}; };
+13 -4
View File
@@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -19,7 +23,9 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": [
"./src/*"
]
} }
}, },
"include": [ "include": [
@@ -28,7 +34,10 @@
"**/*.tsx", "**/*.tsx",
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts", ".next/dev/types/**/*.ts",
"**/*.mts" "**/*.mts",
".next/dev/dev/types/**/*.ts"
], ],
"exclude": ["node_modules"] "exclude": [
"node_modules"
]
} }