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
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:
co-authored by
Claude Opus 4.8
parent
bd9e7d8151
commit
977a6f9653
@@ -10,7 +10,7 @@ import { createAnalysisRouter } from "../modules/analysis/index.js";
|
||||
import { createConfigRouter } from "../modules/config/index.js";
|
||||
import { createDashboardRouter } from "../modules/dashboard/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 { createMessagesRouter } from "../modules/messages/index.js";
|
||||
import { createRecordingsRouter } from "../modules/recordings/index.js";
|
||||
@@ -64,7 +64,7 @@ export function createHttpApp(): Express {
|
||||
app.use("/api", createDashboardRouter());
|
||||
app.use("/api", createMessagesRouter());
|
||||
app.use("/api", createAnalysisRouter());
|
||||
app.use("/api", createMascotChatRouter());
|
||||
app.use("/api", createChatbotRouter());
|
||||
app.use("/api", createRecordingsRouter());
|
||||
app.use("/api", createUiStateRouter());
|
||||
app.use("/api", createMediaRouter());
|
||||
|
||||
+12
-12
@@ -1,15 +1,15 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Request, Response } from "express";
|
||||
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 {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export const handleMascotChat = asyncHandler(
|
||||
export const handleChatbotChat = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
const { message, context } = req.body as {
|
||||
message: string;
|
||||
@@ -29,26 +29,26 @@ export const handleMascotChat = asyncHandler(
|
||||
|
||||
logger.debug(
|
||||
{ userId, messageLength: message.length, context },
|
||||
"Received mascot chat message",
|
||||
"Received chatbot chat message",
|
||||
);
|
||||
|
||||
// Process message & generate response
|
||||
const response = await mascotChatService.processMessage(
|
||||
const response = await chatbotService.processMessage(
|
||||
message,
|
||||
context,
|
||||
userId,
|
||||
);
|
||||
|
||||
// Save conversation to database
|
||||
await mascotChatService.saveConversation({
|
||||
await chatbotService.saveConversation({
|
||||
userId,
|
||||
userMessage: message,
|
||||
mascotResponse: response,
|
||||
botResponse: response,
|
||||
context,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
logger.info({ userId }, "Mascot chat processed successfully");
|
||||
logger.info({ userId }, "Chatbot chat processed successfully");
|
||||
|
||||
res.status(200).json({
|
||||
response,
|
||||
@@ -57,12 +57,12 @@ export const handleMascotChat = asyncHandler(
|
||||
},
|
||||
);
|
||||
|
||||
export const getMascotChatHistory = asyncHandler(
|
||||
export const getChatbotHistory = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId || "anonymous";
|
||||
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({
|
||||
history,
|
||||
@@ -71,11 +71,11 @@ export const getMascotChatHistory = asyncHandler(
|
||||
},
|
||||
);
|
||||
|
||||
export const clearMascotChatHistory = asyncHandler(
|
||||
export const clearChatbotHistory = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId || "anonymous";
|
||||
|
||||
await mascotChatService.clearChatHistory(userId);
|
||||
await chatbotService.clearChatHistory(userId);
|
||||
|
||||
res.status(200).json({
|
||||
message: "Chat history cleared successfully",
|
||||
+20
-20
@@ -1,11 +1,11 @@
|
||||
import { pgMascotChatMessagesTable, pgMessagesTable } from "@bete/shared";
|
||||
import { pgChatbotMessagesTable, pgMessagesTable } from "@bete/shared";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
||||
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;
|
||||
activeParticipants?: number;
|
||||
lastActivity?: string;
|
||||
@@ -17,17 +17,17 @@ export interface MascotChatContext {
|
||||
export interface SaveConversationInput {
|
||||
userId: string;
|
||||
userMessage: string;
|
||||
mascotResponse: string;
|
||||
context?: MascotChatContext;
|
||||
botResponse: string;
|
||||
context?: ChatbotContext;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface MascotChatHistoryRow {
|
||||
export interface ChatbotHistoryRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user_message: string;
|
||||
mascot_response: string;
|
||||
context: MascotChatContext | null;
|
||||
bot_response: string;
|
||||
context: ChatbotContext | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -38,14 +38,14 @@ export interface ServerInsights {
|
||||
warned: number;
|
||||
}
|
||||
|
||||
export class MascotChatRepository {
|
||||
export class ChatbotRepository {
|
||||
async saveConversation(input: SaveConversationInput): Promise<void> {
|
||||
const db = getDatabase();
|
||||
|
||||
await db.insert(pgMascotChatMessagesTable).values({
|
||||
await db.insert(pgChatbotMessagesTable).values({
|
||||
user_id: input.userId,
|
||||
user_message: input.userMessage,
|
||||
mascot_response: input.mascotResponse,
|
||||
bot_response: input.botResponse,
|
||||
context: (input.context ?? {}) as Record<string, unknown>,
|
||||
created_at: input.timestamp,
|
||||
});
|
||||
@@ -56,27 +56,27 @@ export class MascotChatRepository {
|
||||
async getChatHistory(
|
||||
userId: string,
|
||||
limit: number,
|
||||
): Promise<MascotChatHistoryRow[]> {
|
||||
): Promise<ChatbotHistoryRow[]> {
|
||||
const db = getDatabase();
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pgMascotChatMessagesTable)
|
||||
.where(eq(pgMascotChatMessagesTable.user_id, userId))
|
||||
.orderBy(desc(pgMascotChatMessagesTable.created_at))
|
||||
.from(pgChatbotMessagesTable)
|
||||
.where(eq(pgChatbotMessagesTable.user_id, userId))
|
||||
.orderBy(desc(pgChatbotMessagesTable.created_at))
|
||||
.limit(limit);
|
||||
|
||||
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> {
|
||||
const db = getDatabase();
|
||||
|
||||
const deleted = await db
|
||||
.delete(pgMascotChatMessagesTable)
|
||||
.where(eq(pgMascotChatMessagesTable.user_id, userId))
|
||||
.returning({ id: pgMascotChatMessagesTable.id });
|
||||
.delete(pgChatbotMessagesTable)
|
||||
.where(eq(pgChatbotMessagesTable.user_id, userId))
|
||||
.returning({ id: pgChatbotMessagesTable.id });
|
||||
|
||||
logger.info(
|
||||
{ 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;
|
||||
}
|
||||
+17
-17
@@ -1,18 +1,18 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { config } from "../../shared/config/index.js";
|
||||
import type {
|
||||
MascotChatContext,
|
||||
MascotChatHistoryRow,
|
||||
ChatbotContext,
|
||||
ChatbotHistoryRow,
|
||||
SaveConversationInput,
|
||||
} from "./mascot-chat.repository.js";
|
||||
import { mascotChatRepository } from "./mascot-chat.repository.js";
|
||||
} from "./chatbot.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(
|
||||
message: string,
|
||||
context: MascotChatContext | undefined,
|
||||
context: ChatbotContext | undefined,
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
logger.info(
|
||||
@@ -20,7 +20,7 @@ class MascotChatService {
|
||||
"processMessage called",
|
||||
);
|
||||
const recentContext = await this.getRecentConversationContext(userId);
|
||||
const serverInsights = await mascotChatRepository.getServerInsights(
|
||||
const serverInsights = await chatbotRepository.getServerInsights(
|
||||
context?.guildId,
|
||||
context?.channelId,
|
||||
);
|
||||
@@ -39,29 +39,29 @@ class MascotChatService {
|
||||
|
||||
async saveConversation(input: SaveConversationInput): Promise<void> {
|
||||
logger.info({ userId: input.userId }, "saveConversation called");
|
||||
await mascotChatRepository.saveConversation(input);
|
||||
await chatbotRepository.saveConversation(input);
|
||||
}
|
||||
|
||||
async getChatHistory(
|
||||
userId: string,
|
||||
limit: number,
|
||||
): Promise<MascotChatHistoryRow[]> {
|
||||
): Promise<ChatbotHistoryRow[]> {
|
||||
logger.debug({ userId, limit }, "getChatHistory called");
|
||||
return mascotChatRepository.getChatHistory(userId, limit);
|
||||
return chatbotRepository.getChatHistory(userId, limit);
|
||||
}
|
||||
|
||||
async clearChatHistory(userId: string): Promise<void> {
|
||||
logger.info({ userId }, "clearChatHistory called");
|
||||
await mascotChatRepository.clearChatHistory(userId);
|
||||
await chatbotRepository.clearChatHistory(userId);
|
||||
}
|
||||
|
||||
private async getRecentConversationContext(
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
const history = await mascotChatRepository.getChatHistory(userId, 3);
|
||||
const history = await chatbotRepository.getChatHistory(userId, 3);
|
||||
return history.flatMap((row) => [
|
||||
`User: ${row.user_message}`,
|
||||
`Mascot: ${row.mascot_response}`,
|
||||
`Bot: ${row.bot_response}`,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ class MascotChatService {
|
||||
flagged: number;
|
||||
warned: number;
|
||||
}): 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:
|
||||
- Pesan: ${insights.total_messages}
|
||||
@@ -92,7 +92,7 @@ Gaya ngobrol:
|
||||
private buildHistoryMessages(
|
||||
recentContext: string[],
|
||||
): Array<{ role: "user" | "assistant"; content: string }> {
|
||||
// recentContext is alternating User/Mascot messages
|
||||
// recentContext is alternating User/Bot messages
|
||||
return recentContext.map((text) => {
|
||||
if (text.startsWith("User: ")) {
|
||||
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 {
|
||||
pgMascotChatMessagesTable,
|
||||
pgChatbotMessagesTable,
|
||||
pgMuxerJobsTable,
|
||||
pgRetentionPoliciesTable,
|
||||
pgUIStateTable,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
|
||||
// Re-export shared tables
|
||||
export {
|
||||
pgMascotChatMessagesTable,
|
||||
pgChatbotMessagesTable,
|
||||
pgMuxerJobsTable,
|
||||
pgRetentionPoliciesTable,
|
||||
pgUIStateTable,
|
||||
@@ -15,7 +15,7 @@ export {
|
||||
export const muxerJobsTable = pgMuxerJobsTable;
|
||||
export const uiStateTable = pgUIStateTable;
|
||||
export const retentionPoliciesTable = pgRetentionPoliciesTable;
|
||||
export const mascotChatMessagesTable = pgMascotChatMessagesTable;
|
||||
export const chatbotMessagesTable = pgChatbotMessagesTable;
|
||||
|
||||
// Types
|
||||
export type MuxerJob = typeof muxerJobsTable.$inferSelect;
|
||||
@@ -24,6 +24,6 @@ export type UIState = typeof uiStateTable.$inferSelect;
|
||||
export type UIStateInsert = typeof uiStateTable.$inferInsert;
|
||||
export type RetentionPolicy = typeof retentionPoliciesTable.$inferSelect;
|
||||
export type RetentionPolicyInsert = typeof retentionPoliciesTable.$inferInsert;
|
||||
export type MascotChatMessage = typeof mascotChatMessagesTable.$inferSelect;
|
||||
export type MascotChatMessageInsert =
|
||||
typeof mascotChatMessagesTable.$inferInsert;
|
||||
export type ChatbotMessage = typeof chatbotMessagesTable.$inferSelect;
|
||||
export type ChatbotMessageInsert =
|
||||
typeof chatbotMessagesTable.$inferInsert;
|
||||
|
||||
@@ -25,7 +25,7 @@ src/
|
||||
│ ├── messages/ # Message feed, search, review, detail modal
|
||||
│ ├── live/ # Voice connection, music player, recordings
|
||||
│ ├── dashboard/ # Stats, users, channels overview
|
||||
│ └── mascot/ # AI chatbot
|
||||
│ └── chatbot/ # AI chatbot
|
||||
├── lib/
|
||||
│ ├── api/ # Fetch-based API client (all BE endpoints)
|
||||
│ ├── ws/ # WebSocket client + React context
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Suspense, useEffect, useState } from "react";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { MobileNav } from "@/components/layout/mobile-nav";
|
||||
import { WsProvider, useWebSocket } from "@/lib/ws/context";
|
||||
import { MascotProvider, useMascot } from "@/components/mascot/mascot-context";
|
||||
import { MascotContainer } from "@/components/mascot/mascot-container";
|
||||
import { ChatbotProvider, useChatbot } from "@/components/chatbot/chatbot-context";
|
||||
import { ChatbotContainer } from "@/components/chatbot/chatbot-container";
|
||||
import { MiniPlayer } from "@/components/media/mini-player";
|
||||
import { MediaPlayerProvider } from "@/lib/hooks/use-media-player";
|
||||
import { HiddenSidebar } from "@/components/layout/hidden-sidebar";
|
||||
@@ -21,9 +21,9 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
function MascotExpressionSync() {
|
||||
function ChatbotExpressionSync() {
|
||||
const ws = useWebSocket();
|
||||
const { setExpression } = useMascot();
|
||||
const { setExpression } = useChatbot();
|
||||
|
||||
useEffect(() => {
|
||||
const unsub1 = ws.on("message_created", (data: any) => {
|
||||
@@ -57,8 +57,8 @@ export default function DashboardLayout({
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WsProvider>
|
||||
<MediaPlayerProvider>
|
||||
<MascotProvider>
|
||||
<MascotExpressionSync />
|
||||
<ChatbotProvider>
|
||||
<ChatbotExpressionSync />
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<TopNav />
|
||||
<HiddenSidebar guildId={guildId} onGuildChange={(g) => setGuildId(g ?? "")} />
|
||||
@@ -80,9 +80,9 @@ export default function DashboardLayout({
|
||||
|
||||
<MobileNav />
|
||||
<MiniPlayer />
|
||||
<MascotContainer />
|
||||
<ChatbotContainer />
|
||||
</div>
|
||||
</MascotProvider>
|
||||
</ChatbotProvider>
|
||||
</MediaPlayerProvider>
|
||||
</WsProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
+4
-4
@@ -2,14 +2,14 @@
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { Send } from "lucide-react";
|
||||
import { useMascot } from "./mascot-context";
|
||||
import { useChatbot } from "./chatbot-context";
|
||||
|
||||
interface ChatPanelProps {
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
||||
const { messages, sendMessage, isTyping } = useMascot();
|
||||
const { messages, sendMessage, isTyping } = useChatbot();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const internalInputRef = useRef<HTMLInputElement>(null);
|
||||
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">
|
||||
{messages.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
{messages.slice(-8).map((msg, i) => (
|
||||
@@ -72,7 +72,7 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Ask mascot..."
|
||||
placeholder="Ask chatbot..."
|
||||
className="flex-1 bg-transparent text-[10px] text-text-primary placeholder-text-secondary/30 outline-none"
|
||||
disabled={isTyping}
|
||||
/>
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useMascot } from "./mascot-context";
|
||||
import { useChatbot } from "./chatbot-context";
|
||||
|
||||
/**
|
||||
* Live2D Cubism WebGL canvas.
|
||||
@@ -10,15 +10,15 @@ import { useMascot } from "./mascot-context";
|
||||
* Integration requires:
|
||||
* 1. Live2D Cubism SDK for Web (npm: @live2d/cubism)
|
||||
* 2. Model files: .model3.json, .moc3, .physics3.json, textures
|
||||
* 3. Place model files in public/mascot/
|
||||
* 3. Place model files in public/chatbot/
|
||||
*
|
||||
* The current implementation shows a placeholder character.
|
||||
* Replace with actual Cubism SDK integration when model files are available.
|
||||
*/
|
||||
|
||||
export function MascotCanvas() {
|
||||
export function ChatbotCanvas() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const { expression } = useMascot();
|
||||
const { expression } = useChatbot();
|
||||
|
||||
// Placeholder: draw a simple avatar face that responds to expression
|
||||
useEffect(() => {
|
||||
+9
-9
@@ -2,12 +2,12 @@
|
||||
|
||||
import { useRef, useState, useCallback, useEffect } from "react";
|
||||
import { Bot, MessageCircle, Minimize2 } from "lucide-react";
|
||||
import { useMascot } from "./mascot-context";
|
||||
import { MascotCanvas } from "./mascot-canvas";
|
||||
import { useChatbot } from "./chatbot-context";
|
||||
import { ChatbotCanvas } from "./chatbot-canvas";
|
||||
import { ChatPanel } from "./chat-panel";
|
||||
|
||||
export function MascotContainer() {
|
||||
const { minimized, setMinimized, chatOpen, setChatOpen } = useMascot();
|
||||
export function ChatbotContainer() {
|
||||
const { minimized, setMinimized, chatOpen, setChatOpen } = useChatbot();
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
@@ -42,7 +42,7 @@ export function MascotContainer() {
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* Main mascot bubble */}
|
||||
{/* Main chatbot bubble */}
|
||||
<div
|
||||
className={`glass-intense rounded-2xl overflow-hidden transition-all duration-200 ${
|
||||
minimized ? "w-14 h-14 cursor-pointer" : "w-[220px]"
|
||||
@@ -55,7 +55,7 @@ export function MascotContainer() {
|
||||
onClick={() => setMinimized(false)}
|
||||
className="w-full h-full flex items-center justify-center"
|
||||
onMouseDown={handleMouseDown}
|
||||
aria-label="Open mascot"
|
||||
aria-label="Open chatbot"
|
||||
>
|
||||
<Bot className="size-6 text-primary" />
|
||||
</button>
|
||||
@@ -67,7 +67,7 @@ export function MascotContainer() {
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-text-secondary tracking-wide uppercase">
|
||||
Mascot
|
||||
Chatbot
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
@@ -82,7 +82,7 @@ export function MascotContainer() {
|
||||
type="button"
|
||||
onClick={() => setMinimized(true)}
|
||||
className="size-5 flex items-center justify-center rounded hover:bg-glass-bg transition-colors"
|
||||
aria-label="Minimize mascot"
|
||||
aria-label="Minimize chatbot"
|
||||
>
|
||||
{minimized ? (
|
||||
<Bot className="size-3 text-text-secondary/60 hover:text-text-primary" />
|
||||
@@ -95,7 +95,7 @@ export function MascotContainer() {
|
||||
|
||||
{/* Canvas area */}
|
||||
<div className="h-[140px] flex items-center justify-center">
|
||||
<MascotCanvas />
|
||||
<ChatbotCanvas />
|
||||
</div>
|
||||
|
||||
{/* Chat panel (expandable) */}
|
||||
+19
-19
@@ -12,18 +12,18 @@ import {
|
||||
import { chatbotApi } from "@/lib/api";
|
||||
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";
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface MascotContextValue {
|
||||
/** Expression the mascot avatar should display */
|
||||
expression: MascotExpression;
|
||||
setExpression: (expr: MascotExpression) => void;
|
||||
interface ChatbotContextValue {
|
||||
/** Expression the chatbot avatar should display */
|
||||
expression: ChatbotExpression;
|
||||
setExpression: (expr: ChatbotExpression) => void;
|
||||
|
||||
/** Whether the enlarged bubble is minimized to a small icon */
|
||||
minimized: boolean;
|
||||
@@ -42,19 +42,19 @@ interface MascotContextValue {
|
||||
toggle: () => void;
|
||||
|
||||
/** Chat messages with real API backend */
|
||||
messages: MascotMessage[];
|
||||
messages: ChatbotMessage[];
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
clearMessages: () => Promise<void>;
|
||||
isTyping: boolean;
|
||||
}
|
||||
|
||||
const MascotContext = createContext<MascotContextValue | null>(null);
|
||||
const ChatbotContext = createContext<ChatbotContextValue | null>(null);
|
||||
|
||||
export function MascotProvider({ children }: { children: ReactNode }) {
|
||||
const [expression, setExpression] = useState<MascotExpression>("idle");
|
||||
export function ChatbotProvider({ children }: { children: ReactNode }) {
|
||||
const [expression, setExpression] = useState<ChatbotExpression>("idle");
|
||||
const [minimized, setMinimized] = useState(true);
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<MascotMessage[]>([]);
|
||||
const [messages, setMessages] = useState<ChatbotMessage[]>([]);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const historyFetched = useRef(false);
|
||||
|
||||
@@ -89,7 +89,7 @@ export function MascotProvider({ children }: { children: ReactNode }) {
|
||||
const sendMessage = useCallback(async (content: string) => {
|
||||
if (!content.trim()) return;
|
||||
|
||||
const userMsg: MascotMessage = {
|
||||
const userMsg: ChatbotMessage = {
|
||||
role: "user",
|
||||
content: content.trim(),
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -100,7 +100,7 @@ export function MascotProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
try {
|
||||
const res = await chatbotApi.send(content.trim());
|
||||
const botMsg: MascotMessage = {
|
||||
const botMsg: ChatbotMessage = {
|
||||
role: "assistant",
|
||||
content: res.response,
|
||||
timestamp: res.timestamp ?? new Date().toISOString(),
|
||||
@@ -108,7 +108,7 @@ export function MascotProvider({ children }: { children: ReactNode }) {
|
||||
setMessages((prev) => [...prev, botMsg]);
|
||||
setExpression("happy");
|
||||
} catch {
|
||||
const errorMsg: MascotMessage = {
|
||||
const errorMsg: ChatbotMessage = {
|
||||
role: "assistant",
|
||||
content: "Sorry, I couldn't process that request. Please try again.",
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -130,7 +130,7 @@ export function MascotProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MascotContext.Provider
|
||||
<ChatbotContext.Provider
|
||||
value={{
|
||||
expression,
|
||||
setExpression,
|
||||
@@ -148,14 +148,14 @@ export function MascotProvider({ children }: { children: ReactNode }) {
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MascotContext.Provider>
|
||||
</ChatbotContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMascot(): MascotContextValue {
|
||||
const ctx = useContext(MascotContext);
|
||||
export function useChatbot(): ChatbotContextValue {
|
||||
const ctx = useContext(ChatbotContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useMascot must be used within a MascotProvider");
|
||||
throw new Error("useChatbot must be used within a ChatbotProvider");
|
||||
}
|
||||
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">
|
||||
“{channel.culture_summary}”
|
||||
</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">
|
||||
“{ch.culture_summary}”
|
||||
</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,9 +3,9 @@ import { api } from "./client";
|
||||
|
||||
export const chatbotApi = {
|
||||
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"),
|
||||
};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -19,7 +23,9 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -28,7 +34,10 @@
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
"**/*.mts",
|
||||
".next/dev/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user