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
@@ -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"),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user