Revert "feat: migrate frontend to Astro SSG with design system"
This reverts commit 8ad888da28.
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
// ─── Shared HTTP client — all API endpoints in one file ──────────────────────
|
||||
|
||||
import type { MessageRecord, PageResult } from "@bete/shared";
|
||||
import { createLogger } from "../lib/logger.js";
|
||||
import type {
|
||||
ChatResponse,
|
||||
DashboardChannel,
|
||||
@@ -9,20 +8,29 @@ import type {
|
||||
DashboardStats,
|
||||
DashboardUser,
|
||||
DashboardUserDetail,
|
||||
} from "../types/dashboard.js";
|
||||
import type { Channel, Guild, GuildVoiceEntry } from "../types/guild.js";
|
||||
import type { MediaItem, MediaMode, MediaState } from "../types/media.js";
|
||||
} from "../../entities/dashboard/types.js";
|
||||
import type {
|
||||
Channel,
|
||||
Guild,
|
||||
GuildVoiceEntry,
|
||||
} from "../../entities/guild/types.js";
|
||||
import type {
|
||||
MediaItem,
|
||||
MediaMode,
|
||||
MediaState,
|
||||
} from "../../entities/media/types.js";
|
||||
import type {
|
||||
VoiceRecording,
|
||||
VoiceRecordingListResponse,
|
||||
} from "../types/recording.js";
|
||||
} from "../../entities/recording/types.js";
|
||||
import type {
|
||||
AdminSettings,
|
||||
AppConfig,
|
||||
DashboardTab,
|
||||
UIState,
|
||||
} from "../types/ui-types.js";
|
||||
import type { ActiveSpeaker, VoiceStatus } from "../types/voice.js";
|
||||
} from "../../entities/ui/types.js";
|
||||
import type { ActiveSpeaker, VoiceStatus } from "../../entities/voice/types.js";
|
||||
import { createLogger } from "../lib/logger.js";
|
||||
|
||||
const logger = createLogger("api");
|
||||
|
||||
@@ -222,7 +230,9 @@ export function reanalyzeMessage(id: string): Promise<void> {
|
||||
return request<void>(`/api/messages/${id}/reanalyze`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function getMessageById(id: string): Promise<MessageRecord | null> {
|
||||
export function getMessageById(
|
||||
id: string,
|
||||
): Promise<MessageRecord | null> {
|
||||
return request<MessageRecord | null>(`/api/messages/detail/${id}`);
|
||||
}
|
||||
|
||||
@@ -325,9 +335,7 @@ export function deleteRecording(id: string): Promise<void> {
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function login(
|
||||
password: string,
|
||||
): Promise<{ ok: boolean; token?: string }> {
|
||||
export function login(password: string): Promise<{ ok: boolean; token?: string }> {
|
||||
return request<{ ok: boolean; token?: string }>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Moon, Sun, Wifi, WifiOff } from "lucide-react";
|
||||
import { fadeSlideUp } from "../hooks/useFramerStagger";
|
||||
import type { ThemeMode } from "../hooks/useTheme";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { DashboardTab } from "../types/ui-types.js";
|
||||
import type { VoiceStatus } from "../types/voice.js";
|
||||
import type { WsStatus } from "../ws/socket";
|
||||
import { Badge } from "./badge";
|
||||
|
||||
const titles: Record<DashboardTab, string> = {
|
||||
messages: "Messages & Moderation",
|
||||
live: "Voice & Media",
|
||||
dashboard: "Dashboard",
|
||||
settings: "Admin Settings",
|
||||
};
|
||||
|
||||
const subtitles: Record<DashboardTab, string> = {
|
||||
messages: "Capture, analyse, and moderate Discord messages.",
|
||||
live: "Join voice channels, play media, stream audio, and browse recordings.",
|
||||
dashboard: "Server statistics, user profiles, and AI moderation overview.",
|
||||
settings:
|
||||
"Manage dashboard visibility, runtime configuration, and authentication.",
|
||||
};
|
||||
|
||||
interface HeaderProps {
|
||||
activeTab: DashboardTab;
|
||||
wsStatus: WsStatus;
|
||||
voiceStatus: VoiceStatus;
|
||||
themeMode: ThemeMode;
|
||||
isDark: boolean;
|
||||
onThemeToggle: () => void;
|
||||
}
|
||||
|
||||
/** Dot indicator colour for WS badge */
|
||||
function wsDotColor(status: WsStatus): string {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return "bg-emerald-400";
|
||||
case "error":
|
||||
return "bg-red-400";
|
||||
case "connecting":
|
||||
case "disconnected":
|
||||
return "bg-gray-400";
|
||||
}
|
||||
}
|
||||
|
||||
function WsIndicator({ status }: { status: WsStatus }) {
|
||||
const dot = wsDotColor(status);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn("inline-block h-2 w-2 rounded-full", dot)} />
|
||||
<span className="text-xs font-medium capitalize">{status}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Voice status indicator dot */
|
||||
function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) {
|
||||
const isConnected = voiceStatus.connected;
|
||||
const dot = isConnected ? "bg-primary" : "bg-gray-300";
|
||||
const label = isConnected
|
||||
? voiceStatus.activeChannelName || "connected"
|
||||
: "idle";
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn("inline-block h-2 w-2 rounded-full", dot)} />
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header({
|
||||
activeTab,
|
||||
wsStatus,
|
||||
voiceStatus,
|
||||
themeMode,
|
||||
isDark,
|
||||
onThemeToggle,
|
||||
}: HeaderProps) {
|
||||
return (
|
||||
<header className="sticky top-0 z-10 border-b border-border/50 bg-background/70 px-4 py-4 backdrop-blur-sm md:px-8">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
{/* Left: IMPHNEN brand + tab title */}
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
variants={fadeSlideUp}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg"
|
||||
alt="IMPHNEN"
|
||||
className="h-7 w-7"
|
||||
/>
|
||||
<h1 className="text-xl font-bold tracking-tight">
|
||||
<span className="gradient-text">IMPHNEN</span>
|
||||
<span className="mx-2 text-muted-foreground">·</span>
|
||||
{titles[activeTab]}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground hidden md:block">
|
||||
{subtitles[activeTab]}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Right: status badges + theme toggle */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={onThemeToggle}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border bg-card/50 px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
title={`Switch to ${isDark ? "light" : "dark"} mode`}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="h-3.5 w-3.5 text-amber-400" />
|
||||
) : (
|
||||
<Moon className="h-3.5 w-3.5 text-indigo-400" />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{isDark ? "Light" : "Dark"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* WS Badge */}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-border bg-card/50 px-3 py-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{wsStatus === "connected" ? (
|
||||
<Wifi className="mr-1.5 h-3 w-3 text-emerald-400" />
|
||||
) : (
|
||||
<WifiOff className="mr-1.5 h-3 w-3 text-red-400" />
|
||||
)}
|
||||
<WsIndicator status={wsStatus} />
|
||||
</Badge>
|
||||
|
||||
{/* Voice Badge */}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-border bg-card/50 px-3 py-1.5 text-xs",
|
||||
voiceStatus.connected ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<VoiceIndicator voiceStatus={voiceStatus} />
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Bell,
|
||||
LayoutDashboard,
|
||||
MessageSquare,
|
||||
Radio,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { useMascotChat } from "../hooks/useMascotChat";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { MessageRecord } from "../types/message.js";
|
||||
import type { DashboardTab } from "../types/ui-types.js";
|
||||
import { MascotChatbot } from "./mascot/MascotChatbot";
|
||||
import { MascotImage } from "./mascot/MascotImage";
|
||||
|
||||
const navItems: Array<{
|
||||
id: DashboardTab;
|
||||
label: string;
|
||||
icon: typeof Radio;
|
||||
}> = [
|
||||
{ id: "messages", label: "Messages & Moderation", icon: MessageSquare },
|
||||
{ id: "live", label: "Voice & Media", icon: Radio },
|
||||
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ id: "settings" as const, label: "Admin", icon: Settings },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
activeTab: DashboardTab;
|
||||
onTabChange: (tab: DashboardTab) => void;
|
||||
collapsed?: boolean;
|
||||
recentMessages?: MessageRecord[];
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
notificationCount?: number;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
collapsed = true,
|
||||
recentMessages = [],
|
||||
guildId,
|
||||
channelId,
|
||||
notificationCount = 0,
|
||||
}: SidebarProps) {
|
||||
const mascotChat = useMascotChat({
|
||||
messageCount: recentMessages.length,
|
||||
activeParticipants: new Set(
|
||||
recentMessages.map((message) => message.user_id),
|
||||
).size,
|
||||
lastActivity: recentMessages.length > 0 ? "Active" : "Idle",
|
||||
topicsDiscussed: ["Messages", "Moderation"],
|
||||
guildId,
|
||||
channelId,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<motion.nav
|
||||
className={cn(
|
||||
"relative hidden shrink-0 flex-col overflow-visible border-r border-border/50 bg-background/70 backdrop-blur-sm transition-all duration-300 md:flex",
|
||||
collapsed ? "w-16" : "w-56 lg:w-64",
|
||||
)}
|
||||
layout
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
>
|
||||
{/* App icon only — no branding text */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-5",
|
||||
collapsed ? "justify-center" : "flex-col px-4",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg"
|
||||
alt="IMPHNEN"
|
||||
className="h-8 w-8 rounded-xl"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Mascot image — only when expanded */}
|
||||
{!collapsed && (
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
|
||||
alt="Mascot"
|
||||
className="mt-4 h-auto w-[140px] object-contain drop-shadow-md"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation items — centered vertically */}
|
||||
<div className="flex flex-1 flex-col justify-center">
|
||||
<div className="flex flex-col gap-1 px-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onTabChange(item.id)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={cn(
|
||||
"group relative flex items-center rounded-xl p-2.5 text-sm font-medium transition-all duration-200 ease-out",
|
||||
collapsed ? "justify-center" : "gap-3",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary ring-1 ring-primary/20"
|
||||
: "text-muted-foreground hover:bg-primary/5 hover:text-primary/70",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
{!collapsed &&
|
||||
item.id === "messages" &&
|
||||
notificationCount !== undefined &&
|
||||
notificationCount > 0 && (
|
||||
<span className="ml-auto flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground">
|
||||
{notificationCount > 99 ? "99+" : notificationCount}
|
||||
</span>
|
||||
)}
|
||||
{/* Collapsed badge — top-right dot */}
|
||||
{collapsed &&
|
||||
item.id === "messages" &&
|
||||
notificationCount !== undefined &&
|
||||
notificationCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-destructive text-[7px] font-bold text-destructive-foreground">
|
||||
{notificationCount > 9 ? "N" : notificationCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mascot button */}
|
||||
<div className="flex justify-center pb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mascotChat.setIsOpen(!mascotChat.isOpen)}
|
||||
className="relative z-50 rounded-xl p-1 transition-transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
title="Chat dengan mascot"
|
||||
>
|
||||
<MascotImage size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.nav>
|
||||
<MascotChatbot
|
||||
isOpen={mascotChat.isOpen}
|
||||
onClose={() => mascotChat.setIsOpen(false)}
|
||||
onSendMessage={mascotChat.handleSendMessage}
|
||||
mascotName="IMPHNEN Mascot"
|
||||
className="fixed bottom-[170px] left-[80px] z-[9999] md:bottom-4 md:left-4 md:right-auto"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
// ─── AuthGuard.client.tsx — Astro React island for login page ──────────────
|
||||
// Thin wrapper around AuthOverlay that redirects to the main app on success.
|
||||
// Loaded via client:load on the login page.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { AuthOverlay } from "./index";
|
||||
|
||||
interface AuthGuardProps {
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export default function AuthGuard({ redirectTo = "/live" }: AuthGuardProps) {
|
||||
const handleAuthenticated = useCallback(() => {
|
||||
window.location.href = redirectTo;
|
||||
}, [redirectTo]);
|
||||
|
||||
return <AuthOverlay isPublic={false} onAuthenticated={handleAuthenticated} />;
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Lock, RefreshCw, Shield, Unlock, WifiOff } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { login, setSessionToken } from "../../api/client.js";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
} from "../index";
|
||||
|
||||
interface AuthOverlayProps {
|
||||
onAuthenticated: () => void;
|
||||
isPublic: boolean;
|
||||
configError?: string | null;
|
||||
onRetryConfig?: () => void;
|
||||
}
|
||||
|
||||
export function AuthOverlay({
|
||||
onAuthenticated,
|
||||
isPublic,
|
||||
configError,
|
||||
onRetryConfig,
|
||||
}: AuthOverlayProps) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isNetworkError, setIsNetworkError] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setIsNetworkError(false);
|
||||
try {
|
||||
const result = await login(password);
|
||||
// Store session token (new auth method)
|
||||
if (result.token) {
|
||||
setSessionToken(result.token);
|
||||
}
|
||||
// Clean up legacy stored password from localStorage if it was there
|
||||
// from a previous session (before JWT migration)
|
||||
localStorage.removeItem("admin-password");
|
||||
onAuthenticated();
|
||||
} catch (err) {
|
||||
const isNetwork =
|
||||
err instanceof TypeError &&
|
||||
(err.message === "Failed to fetch" ||
|
||||
err.message.includes("NetworkError") ||
|
||||
err.message.includes("network"));
|
||||
setIsNetworkError(isNetwork);
|
||||
setError(
|
||||
isNetwork
|
||||
? "Cannot reach server — check your connection or try again."
|
||||
: "Invalid password",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Retry config fetch (initial loading state) ──────────────────────────────
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
setRetryCount((r) => r + 1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
className="flex min-h-screen items-center justify-center p-4"
|
||||
>
|
||||
<Card className="w-full max-w-md border-primary/30 shadow-lg shadow-primary/10">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex items-center justify-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
{isPublic ? (
|
||||
<Shield className="h-6 w-6" />
|
||||
) : (
|
||||
<Lock className="h-6 w-6" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle>
|
||||
{isPublic ? "Admin Authentication" : "Admin Access Required"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{isPublic
|
||||
? "Enter the admin password to manage settings and perform administrative actions."
|
||||
: "Enter the admin password to access the dashboard."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{configError && (
|
||||
<div className="mb-4 flex flex-col items-center gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 text-center">
|
||||
<WifiOff className="h-6 w-6 text-amber-500" />
|
||||
<p className="text-xs text-amber-600">{configError}</p>
|
||||
{onRetryConfig && (
|
||||
<Button
|
||||
onClick={onRetryConfig}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 border-amber-500/30 text-amber-600 hover:bg-amber-500/10"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Retry Connection
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter admin password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-lg p-2 text-xs ${
|
||||
isNetworkError
|
||||
? "bg-amber-500/10 text-amber-600"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{isNetworkError ? (
|
||||
<WifiOff className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading || !password}
|
||||
>
|
||||
{loading ? "Authenticating..." : "Unlock"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{isPublic && (
|
||||
<p className="mt-4 text-xs text-center text-muted-foreground">
|
||||
<Unlock className="inline h-3 w-3 mr-1" />
|
||||
The dashboard is in public mode — most data is visible without
|
||||
authentication. Admin password is only needed for management
|
||||
actions.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Maximize2, MessageCircle, Minimize2, Send, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const logger = createLogger("mascot-chat");
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "mascot";
|
||||
content: string;
|
||||
timestamp: number;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface MascotChatbotProps {
|
||||
onClose?: () => void;
|
||||
isOpen?: boolean;
|
||||
onSendMessage?: (message: string) => Promise<string>;
|
||||
mascotName?: string;
|
||||
mascotAvatar?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MascotChatbot({
|
||||
onClose,
|
||||
isOpen = false,
|
||||
onSendMessage,
|
||||
mascotName = "Mascot",
|
||||
mascotAvatar = "https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png",
|
||||
className,
|
||||
}: MascotChatbotProps) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
id: "init-1",
|
||||
role: "mascot",
|
||||
content:
|
||||
"Halo! 👋 Saya mascot mu. Ada yang bisa aku bantu tentang conversation atau analytics?",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const handleSendMessage = async (e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || loading) return;
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user",
|
||||
content: input.trim(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInput("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
let response = "Aku sedang memproses pertanyaanmu...";
|
||||
|
||||
if (onSendMessage) {
|
||||
response = await onSendMessage(input.trim());
|
||||
} else {
|
||||
// Default mascot responses
|
||||
response = generateMascotResponse(input.trim(), messages);
|
||||
}
|
||||
|
||||
const mascotMessage: ChatMessage = {
|
||||
id: `mascot-${Date.now()}`,
|
||||
role: "mascot",
|
||||
content: response,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, mascotMessage]);
|
||||
} catch (error) {
|
||||
logger.error("Error sending message", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
const errorMessage: ChatMessage = {
|
||||
id: `mascot-error-${Date.now()}`,
|
||||
role: "mascot",
|
||||
content: "Maaf, ada error saat aku memproses. Coba lagi ya! 😅",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
className={cn(
|
||||
"w-96 bg-card rounded-xl shadow-2xl border border-border overflow-hidden flex flex-col",
|
||||
isMinimized ? "h-16" : "h-[520px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-primary to-primary/80 text-primary-foreground p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary-foreground/20 flex items-center justify-center">
|
||||
<MessageCircle className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">{mascotName}</h3>
|
||||
<p className="text-xs text-primary-foreground/80">
|
||||
{loading ? "Mengetik..." : "Online"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
className="p-1.5 hover:bg-primary-foreground/20 rounded-lg transition-colors"
|
||||
title={isMinimized ? "Maximize" : "Minimize"}
|
||||
>
|
||||
{isMinimized ? (
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
)}
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => {
|
||||
onClose?.();
|
||||
}}
|
||||
className="p-1.5 hover:bg-primary-foreground/20 rounded-lg transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{!isMinimized && (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-card">
|
||||
{messages.map((message) => (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn(
|
||||
"flex gap-2",
|
||||
message.role === "user" ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
{message.role === "mascot" && (
|
||||
<img
|
||||
src={mascotAvatar}
|
||||
alt={mascotName}
|
||||
className="w-6 h-6 rounded-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-xs px-3 py-2 rounded-xl text-sm break-words",
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground rounded-br-none"
|
||||
: "bg-muted text-foreground rounded-bl-none",
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
{loading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex gap-2 justify-start"
|
||||
>
|
||||
<img
|
||||
src={mascotAvatar}
|
||||
alt={mascotName}
|
||||
className="w-6 h-6 rounded-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<div className="bg-muted rounded-xl rounded-bl-none px-3 py-2">
|
||||
<div className="flex gap-1">
|
||||
<motion.div
|
||||
animate={{ y: [0, -4, 0] }}
|
||||
transition={{ duration: 0.6, repeat: Infinity }}
|
||||
className="w-2 h-2 bg-muted-foreground rounded-full"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ y: [0, -4, 0] }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
repeat: Infinity,
|
||||
delay: 0.1,
|
||||
}}
|
||||
className="w-2 h-2 bg-muted-foreground rounded-full"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ y: [0, -4, 0] }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
repeat: Infinity,
|
||||
delay: 0.2,
|
||||
}}
|
||||
className="w-2 h-2 bg-muted-foreground rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<form
|
||||
onSubmit={handleSendMessage}
|
||||
className="border-t border-border p-3 bg-card"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Tanya mascot..."
|
||||
disabled={loading}
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-input bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring text-sm disabled:opacity-50"
|
||||
/>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="submit"
|
||||
disabled={loading || !input.trim()}
|
||||
className="p-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
// Default mascot responses based on keywords
|
||||
function generateMascotResponse(
|
||||
input: string,
|
||||
messages: ChatMessage[],
|
||||
): string {
|
||||
const lowerInput = input.toLowerCase();
|
||||
const responseMap: Record<string, string> = {
|
||||
halo: "Halo juga! 👋 Senang ketemu kamu. Ada yang bisa aku bantu?",
|
||||
terima: "Sama-sama! 😊",
|
||||
apa: "Aku adalah mascot virtual yang membantu kamu memahami conversation dan analytics. Tanya aku apa saja!",
|
||||
siapa:
|
||||
"Aku mascot mu yang baik hati! Siap membantu dengan insights tentang chat dan analytics.",
|
||||
chat: "Setiap chat yang terjadi di sini aku analisis untuk memberikan insights yang berguna. Keren kan? 😎",
|
||||
pesan:
|
||||
"Aku bisa memberikan ringkasan tentang pesan-pesan yang dikirim, siapa yang paling aktif, dan topik populer!",
|
||||
analitik:
|
||||
"Analytics menunjukkan pola conversation, waktu aktif, partisipan utama, dan banyak hal menarik lainnya! 📊",
|
||||
berapa:
|
||||
"Tanya aku 'berapa pesan hari ini' atau 'berapa orang yang chat' dan aku akan jawab dengan data real-time!",
|
||||
};
|
||||
|
||||
for (const [keyword, response] of Object.entries(responseMap)) {
|
||||
if (lowerInput.includes(keyword)) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
// Default response
|
||||
if (messages.length < 5) {
|
||||
return "Bagus! Aku akan belajar tentang apa yang kamu tanya. Coba tanya aku tentang chat, analytics, atau partisipan! 🎯";
|
||||
}
|
||||
|
||||
return `Menarik! "${input}" - itu hal yang perlu diperhatikan. Ada yang lain ingin kamu ketahui? 🤔`;
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { MessageCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* MascotImage — Anime mascot PNG from GitHub CDN
|
||||
* Replaces ChibiMascot SVG component with external PNG asset
|
||||
* Now includes optional floating chat bubble with AI insights
|
||||
*/
|
||||
|
||||
interface MascotImageProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
showChat?: boolean;
|
||||
chatMessage?: string;
|
||||
persistChat?: boolean;
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: "w-16 h-auto",
|
||||
md: "w-32 h-auto",
|
||||
lg: "w-48 h-auto",
|
||||
};
|
||||
|
||||
const chatSizeMap = {
|
||||
sm: "max-w-xs",
|
||||
md: "max-w-sm",
|
||||
lg: "max-w-md",
|
||||
};
|
||||
|
||||
export function MascotImage({
|
||||
size = "md",
|
||||
className = "",
|
||||
showChat = false,
|
||||
chatMessage = "",
|
||||
persistChat = false,
|
||||
}: MascotImageProps) {
|
||||
const sizeClass = sizeMap[size];
|
||||
const chatSizeClass = chatSizeMap[size];
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (showChat && chatMessage) {
|
||||
setIsVisible(true);
|
||||
if (persistChat) return;
|
||||
const timer = setTimeout(() => setIsVisible(false), 8000); // Auto hide after 8s
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsVisible(false);
|
||||
}, [showChat, chatMessage, persistChat]);
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
{imgError ? (
|
||||
<div
|
||||
className={`flex items-center justify-center ${sizeClass} bg-muted/30 rounded-xl`}
|
||||
>
|
||||
<MessageCircle className="h-6 w-6 text-muted-foreground/50" />
|
||||
</div>
|
||||
) : (
|
||||
<motion.img
|
||||
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
|
||||
alt="Mascot"
|
||||
className={`object-contain drop-shadow-md ${sizeClass} ${className}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floating Chat Bubble */}
|
||||
{isVisible && chatMessage && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.8 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.8 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
className={`absolute -top-2 -right-2 ${chatSizeClass} pointer-events-none`}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* Chat bubble */}
|
||||
<div className="bg-primary/90 text-primary-foreground rounded-xl px-4 py-2.5 shadow-lg backdrop-blur-sm border border-primary/30">
|
||||
<div className="flex items-start gap-2">
|
||||
<MessageCircle className="h-4 w-4 shrink-0 mt-0.5 text-primary-foreground/80" />
|
||||
<p className="text-xs leading-relaxed font-medium line-clamp-3">
|
||||
{chatMessage}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Chat bubble tail */}
|
||||
<div className="absolute -bottom-1 -left-1 w-3 h-3 bg-primary/80 rounded-full opacity-70" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* EmptyStateMascot — Mascot for empty states
|
||||
* Replaces ChibiMascot when showing empty data states
|
||||
*/
|
||||
export function EmptyStateMascot() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
||||
<MascotImage size="md" className="opacity-60" />
|
||||
<p className="text-sm text-muted-foreground">No data to display</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ParticleBackground() {
|
||||
const [reducedMotion, setReducedMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
setReducedMotion(mq.matches);
|
||||
const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
if (reducedMotion) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none overflow-hidden"
|
||||
aria-hidden="true"
|
||||
style={{ zIndex: -1 }}
|
||||
>
|
||||
{/* Top-right glow orb */}
|
||||
<div
|
||||
className="absolute -top-40 -right-40 h-[500px] w-[500px] rounded-full blur-3xl animate-glow-pulse"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"oklch(var(--particle-primary, 0.623 0.214 259.815 / 0.1))",
|
||||
}}
|
||||
/>
|
||||
{/* Bottom-left glow orb */}
|
||||
<div
|
||||
className="absolute -bottom-40 -left-40 h-[400px] w-[400px] rounded-full blur-3xl animate-glow-pulse"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"oklch(var(--particle-secondary, 0.552 0.016 285.938 / 0.1))",
|
||||
animationDelay: "1.5s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ChatResponse } from "../../entities/dashboard/types.js";
|
||||
import { request } from "../api/client";
|
||||
import { createLogger } from "../lib/logger";
|
||||
import type { ChatResponse } from "../types/dashboard.js";
|
||||
|
||||
const logger = createLogger("useMascotChat");
|
||||
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import type { MessageRecord } from "@bete/shared";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useMessageStore } from "../../stores/message-store.js";
|
||||
import { listMessages, reanalyzeMessage } from "../api/client.js";
|
||||
import { createLogger } from "../lib/logger.js";
|
||||
|
||||
const logger = createLogger("use-messages");
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Merge two message arrays, deduplicating by id and sorting by created_at desc.
|
||||
* Later entries overwrite earlier ones for the same id (useful for WS updates).
|
||||
*/
|
||||
export function mergeMessages(
|
||||
current: MessageRecord[],
|
||||
incoming: MessageRecord[],
|
||||
): MessageRecord[] {
|
||||
const byId = new Map(current.map((message) => [message.id, message]));
|
||||
for (const message of incoming) {
|
||||
byId.set(message.id, { ...byId.get(message.id), ...message });
|
||||
}
|
||||
return Array.from(byId.values()).sort(
|
||||
(a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching, paginating, and re-analyzing messages.
|
||||
* Reads/writes through the zustand `useMessageStore` so that WebSocket updates
|
||||
* (handled by the store directly via prependMessage/updateMessage/removeMessage)
|
||||
* are reflected without duplicating state.
|
||||
*/
|
||||
export function useMessages() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const currentGuild = useRef<string | null>(null);
|
||||
const { messages, setMessages } = useMessageStore();
|
||||
|
||||
const fetchMessages = useCallback(async (guildId?: string) => {
|
||||
if (!guildId) {
|
||||
setMessages([]);
|
||||
setCursor(null);
|
||||
setHasMore(false);
|
||||
return [];
|
||||
}
|
||||
currentGuild.current = guildId;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await listMessages({
|
||||
guildId,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
// Guard against stale responses if guildId changed mid-flight
|
||||
if (currentGuild.current === guildId) {
|
||||
setMessages(result.data);
|
||||
setCursor(result.nextCursor);
|
||||
setHasMore(!!result.nextCursor);
|
||||
}
|
||||
return result.data;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
logger.error("Failed to fetch messages", { guildId, error: message });
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!cursor || !currentGuild.current || loadingMore) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const result = await listMessages({
|
||||
guildId: currentGuild.current,
|
||||
cursor,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
setMessages((prev) => mergeMessages(prev, result.data));
|
||||
setCursor(result.nextCursor);
|
||||
setHasMore(!!result.nextCursor);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error("Failed to load more messages", { error: message });
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [cursor, loadingMore]);
|
||||
|
||||
const reanalyze = useCallback(async (id: string): Promise<void> => {
|
||||
// Snapshot the current state so we can revert on HTTP failure
|
||||
let saved: MessageRecord | undefined;
|
||||
|
||||
setMessages((prev) => {
|
||||
saved = prev.find((m) => m.id === id);
|
||||
return prev.map((message) =>
|
||||
message.id === id
|
||||
? {
|
||||
...message,
|
||||
ai_status: "pending" as const,
|
||||
ai_error: null,
|
||||
ai_analysis: null,
|
||||
}
|
||||
: message,
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await reanalyzeMessage(id);
|
||||
} catch (err) {
|
||||
// Revert optimistic update on failure
|
||||
if (saved) {
|
||||
const snapshot = saved;
|
||||
setMessages((prev) =>
|
||||
prev.map((message) => (message.id === id ? snapshot : message)),
|
||||
);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error("Failed to reanalyze message", { id, error: message });
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
fetchMessages,
|
||||
reanalyze,
|
||||
loadMore,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
export type ThemeMode = Theme | "system";
|
||||
|
||||
const THEME_STORAGE_KEY = "bete-dashboard-theme";
|
||||
|
||||
function getSystemTheme(): Theme {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
function loadThemeMode(): ThemeMode {
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark" || stored === "system")
|
||||
return stored;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
function resolveTheme(mode: ThemeMode): Theme {
|
||||
return mode === "system" ? getSystemTheme() : mode;
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
const root = document.documentElement;
|
||||
root.setAttribute("data-theme", theme);
|
||||
// Also toggle Tailwind dark class for utility-based approach
|
||||
if (theme === "dark") {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
root.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [mode, setModeState] = useState<ThemeMode>(loadThemeMode);
|
||||
|
||||
const theme = useMemo(() => resolveTheme(mode), [mode]);
|
||||
|
||||
const setMode = useCallback((newMode: ThemeMode) => {
|
||||
setModeState(newMode);
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, newMode);
|
||||
} catch {
|
||||
// ignore quota errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setMode(theme === "dark" ? "light" : "dark");
|
||||
}, [theme, setMode]);
|
||||
|
||||
// Apply theme on mount and when mode changes
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
// Listen for system preference changes when in "system" mode
|
||||
useEffect(() => {
|
||||
if (mode !== "system") return;
|
||||
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = () => {
|
||||
applyTheme(resolveTheme("system"));
|
||||
};
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, [mode]);
|
||||
|
||||
return { theme, mode, setMode, toggle, isDark: theme === "dark" };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import type { UIState } from "../types/ui-types.js";
|
||||
import type { UIState } from "../../entities/ui/types.js";
|
||||
import { uiStateValidator, useLocalStorage } from "./useLocalStorage";
|
||||
|
||||
export function useUIState() {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
export interface DashboardStats {
|
||||
total_messages: number;
|
||||
total_users: number;
|
||||
total_flagged: number;
|
||||
total_clean: number;
|
||||
total_warned: number;
|
||||
total_error: number;
|
||||
total_voice_recordings: number;
|
||||
total_profiles: number;
|
||||
today_messages: number;
|
||||
today_flagged: number;
|
||||
active_users_24h: number;
|
||||
top_channels: Array<{
|
||||
channel_id: string;
|
||||
channel_name: string | null;
|
||||
message_count: number;
|
||||
}>;
|
||||
moderation_overview: {
|
||||
pending: number;
|
||||
processing: number;
|
||||
error: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DashboardUser {
|
||||
user_id: string;
|
||||
username: string | null;
|
||||
avatar_url: string | null;
|
||||
profile_summary: string | null;
|
||||
total_messages: number;
|
||||
flagged_count: number;
|
||||
last_message_at: number | null;
|
||||
trust_score: number | null;
|
||||
}
|
||||
|
||||
export interface DashboardUserDetail extends DashboardUser {
|
||||
last_analyzed_at: number | null;
|
||||
clean_message_streak: number | null;
|
||||
total_infractions: number | null;
|
||||
clean_count: number;
|
||||
recent_messages: Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
channel_id: string;
|
||||
created_at: number;
|
||||
ai_status: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DashboardChannel {
|
||||
channel_id: string;
|
||||
channel_name: string | null;
|
||||
guild_id: string | null;
|
||||
total_messages: number;
|
||||
flagged_count: number;
|
||||
last_message_at: number | null;
|
||||
culture_summary: string | null;
|
||||
last_analyzed_at: number | null;
|
||||
}
|
||||
|
||||
export interface DashboardChannelDetail extends DashboardChannel {
|
||||
clean_count: number;
|
||||
recent_messages: Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
channel_id: string;
|
||||
created_at: number;
|
||||
ai_status: string | null;
|
||||
username: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
response?: string;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export interface Guild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
export interface GuildVoiceEntry {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
connectedAt: number;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
export type MediaMode = "music" | "screen";
|
||||
|
||||
export interface MediaItem {
|
||||
id?: string;
|
||||
source: string;
|
||||
title: string;
|
||||
mode?: "music" | "screen";
|
||||
durationMs?: number | null;
|
||||
thumbnailUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface MediaState {
|
||||
playing: boolean;
|
||||
musicVolume: number;
|
||||
current: MediaItem | null;
|
||||
queue: MediaItem[];
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export type {
|
||||
AIRecommendedAction,
|
||||
AISeverity,
|
||||
AIStatus,
|
||||
MessageRecord,
|
||||
PageResult,
|
||||
} from "@bete/shared";
|
||||
|
||||
export interface MessageMetadata {
|
||||
stickers?: Array<{ name?: string; url?: string }>;
|
||||
attachments?: Array<{ name: string; url: string; contentType?: string }>;
|
||||
embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>;
|
||||
channel?: {
|
||||
channelId: string;
|
||||
channelName?: string;
|
||||
threadId?: string;
|
||||
threadName?: string;
|
||||
};
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
export interface VoiceRecording {
|
||||
id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
guild_id: string | null;
|
||||
channel_id: string | null;
|
||||
channel_name: string | null;
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
download_url: string | null;
|
||||
upload_status: "pending" | "uploaded" | "failed";
|
||||
upload_error: string | null;
|
||||
transcription?: string | null;
|
||||
created_at: number;
|
||||
uploaded_at: number | null;
|
||||
}
|
||||
|
||||
export interface VoiceRecordingListResponse {
|
||||
items: VoiceRecording[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export interface UIState {
|
||||
selectedGuild?: string;
|
||||
selectedVoiceGuild?: string;
|
||||
selectedVoiceChannel?: string;
|
||||
selectedTextGuild?: string;
|
||||
selectedTextChannel?: string;
|
||||
selectedAnalyticsGuild?: string;
|
||||
selectedAnalyticsChannel?: string;
|
||||
activeTab?: DashboardTab;
|
||||
isListening?: boolean;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export type DashboardTab = "live" | "messages" | "dashboard" | "settings";
|
||||
|
||||
export interface AppConfig {
|
||||
monitorGuildId: string | null;
|
||||
dashboardIsPublic: boolean;
|
||||
}
|
||||
|
||||
/** Response from GET /api/admin/settings */
|
||||
export interface AdminSettings {
|
||||
dashboardIsPublic: boolean;
|
||||
envDashboardIsPublic: boolean;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { GuildVoiceEntry } from "./guild";
|
||||
|
||||
export interface VoiceStatus {
|
||||
connected: boolean;
|
||||
activeGuildId: string | null;
|
||||
activeChannelId: string | null;
|
||||
activeChannelName: string | null;
|
||||
connections: GuildVoiceEntry[];
|
||||
}
|
||||
|
||||
export interface ActiveSpeaker {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
speaking: boolean;
|
||||
}
|
||||
+1
-1
@@ -13,11 +13,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
} from "react";
|
||||
import type { MessageRecord } from "../api/client";
|
||||
import { request } from "../api/client";
|
||||
+2
-4
@@ -1,7 +1,7 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { LayoutDashboard, MessageSquare, Radio, Settings } from "lucide-react";
|
||||
import type { DashboardTab } from "../../entities/ui/types.js";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { DashboardTab } from "../types/ui-types.js";
|
||||
|
||||
const tabs: Array<{ id: DashboardTab; label: string; Icon: typeof Radio }> = [
|
||||
{ id: "messages", label: "Messages", Icon: MessageSquare },
|
||||
@@ -44,9 +44,7 @@ export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
|
||||
className="absolute -top-px left-1/4 right-1/4 h-0.5 rounded-full bg-primary"
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
className={cn("h-5 w-5", activeTab === id && "drop-shadow-sm")}
|
||||
/>
|
||||
<Icon className={cn("h-5 w-5", activeTab === id && "drop-shadow-sm")} />
|
||||
<span className="text-[10px]">{label}</span>
|
||||
{activeTab === id && (
|
||||
<motion.div
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./button";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
@@ -6,9 +6,9 @@ import type {
|
||||
VoiceRecordingUploadData,
|
||||
} from "@bete/shared";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getSessionToken } from "../api/client.js";
|
||||
import type { MediaState } from "../../entities/media/types.js";
|
||||
import { createLogger } from "../lib/logger.js";
|
||||
import type { MediaState } from "../types/media.js";
|
||||
import { getSessionToken } from "../api/client.js";
|
||||
import type { ActiveSpeakerData } from "./events.js";
|
||||
|
||||
const logger = createLogger("socket");
|
||||
@@ -391,43 +391,3 @@ export function useDashboardSocket(handlers: WsHandlers) {
|
||||
|
||||
return { status, send, socketRef: { current: _wsInstance } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton manager for the WebSocket connection.
|
||||
* Provides imperative connect/disconnect/send access alongside useDashboardSocket.
|
||||
*/
|
||||
export class SocketManager {
|
||||
private static _instance: SocketManager;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): SocketManager {
|
||||
if (!SocketManager._instance) {
|
||||
SocketManager._instance = new SocketManager();
|
||||
}
|
||||
return SocketManager._instance;
|
||||
}
|
||||
|
||||
/** Ensure the WebSocket is connected (reconnects if closed). */
|
||||
connect(): void {
|
||||
_closed = false;
|
||||
ensureConnected();
|
||||
}
|
||||
|
||||
/** Close the WebSocket and stop reconnection. */
|
||||
disconnect(): void {
|
||||
_closed = true;
|
||||
if (_reconnectTimer) clearTimeout(_reconnectTimer);
|
||||
if (_wsInstance) {
|
||||
_wsInstance.close();
|
||||
_wsInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Send data through the WebSocket (no-op if not connected). */
|
||||
send(data: ArrayBuffer | string): void {
|
||||
if (_wsInstance?.readyState === WebSocket.OPEN) {
|
||||
_wsInstance.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user