feat: migrate frontend to Astro SSG with design system
- Replace monolithic React SPA (App.tsx) with 7 Astro pages (+ routing) - Implement full design system from design/ (OKLCH tokens, Tailwind 4 @theme) - Add 12 Astro components (Button, Badge, Card, Sidebar, Header, states) - Add 11 React islands (AuthGuard, MessageFeed, VoiceControls, AudioVisualizer, etc.) - Add 3 Zustand stores (UI, voice, message state) - Add CSS architecture: dark/light themes, glassmorphism, fluid typography, animations - Add 404, login, settings, recordings pages - Remove 40+ legacy React SPA files - Add Particles background and MascotChat deferred islands - Wire WebSocket bridge for real-time messages and voice events Build: 7 pages in ~900ms Typecheck: clean (0 errors) Lint: clean (0 errors)
This commit is contained in:
1 parent
5e40b1ad8c
commit
8ad888da28
110 files changed
+3396
-4980
No files matched your search
@@ -1,6 +1,7 @@
|
||||
// ─── 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,
|
||||
@@ -8,29 +9,20 @@ import type {
|
||||
DashboardStats,
|
||||
DashboardUser,
|
||||
DashboardUserDetail,
|
||||
} 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";
|
||||
} from "../types/dashboard.js";
|
||||
import type { Channel, Guild, GuildVoiceEntry } from "../types/guild.js";
|
||||
import type { MediaItem, MediaMode, MediaState } from "../types/media.js";
|
||||
import type {
|
||||
VoiceRecording,
|
||||
VoiceRecordingListResponse,
|
||||
} from "../../entities/recording/types.js";
|
||||
} from "../types/recording.js";
|
||||
import type {
|
||||
AdminSettings,
|
||||
AppConfig,
|
||||
DashboardTab,
|
||||
UIState,
|
||||
} from "../../entities/ui/types.js";
|
||||
import type { ActiveSpeaker, VoiceStatus } from "../../entities/voice/types.js";
|
||||
import { createLogger } from "../lib/logger.js";
|
||||
} from "../types/ui-types.js";
|
||||
import type { ActiveSpeaker, VoiceStatus } from "../types/voice.js";
|
||||
|
||||
const logger = createLogger("api");
|
||||
|
||||
@@ -230,9 +222,7 @@ 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}`);
|
||||
}
|
||||
|
||||
@@ -335,7 +325,9 @@ 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
-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";
|
||||
@@ -0,0 +1,154 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+4
-2
@@ -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,7 +44,9 @@ 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
|
||||
@@ -0,0 +1,162 @@
|
||||
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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// ─── 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} />;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./button";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,309 @@
|
||||
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? 🤔`;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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");
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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 "../../entities/ui/types.js";
|
||||
import type { UIState } from "../types/ui-types.js";
|
||||
import { uiStateValidator, useLocalStorage } from "./useLocalStorage";
|
||||
|
||||
export function useUIState() {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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[];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import type {
|
||||
VoiceRecordingUploadData,
|
||||
} from "@bete/shared";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { MediaState } from "../../entities/media/types.js";
|
||||
import { createLogger } from "../lib/logger.js";
|
||||
import { getSessionToken } from "../api/client.js";
|
||||
import { createLogger } from "../lib/logger.js";
|
||||
import type { MediaState } from "../types/media.js";
|
||||
import type { ActiveSpeakerData } from "./events.js";
|
||||
|
||||
const logger = createLogger("socket");
|
||||
@@ -391,3 +391,43 @@ 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