Revert "feat: migrate frontend to Astro SSG with design system"

This reverts commit 8ad888da28.
This commit is contained in:
asepharyana
2026-07-02 03:54:44 +07:00
parent 880c68fbab
commit bf86fcda8e
110 changed files with 4980 additions and 3396 deletions
@@ -0,0 +1,85 @@
import { AnimatePresence, motion } from "framer-motion";
import type { ReactNode } from "react";
import type { MessageRecord } from "../entities/message/types.js";
import type { DashboardTab } from "../entities/ui/types.js";
import type { VoiceStatus } from "../entities/voice/types.js";
import type { ThemeMode } from "../hooks/useTheme";
import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
import type { WsStatus } from "../shared/ws/socket";
import { Header } from "./Header";
import { ParticleBackground } from "./particles/ParticleBackground";
import { Sidebar } from "./Sidebar";
interface DashboardLayoutProps {
activeTab: DashboardTab;
wsStatus: WsStatus;
voiceStatus: VoiceStatus;
themeMode: ThemeMode;
isDark: boolean;
onTabChange: (tab: DashboardTab) => void;
onThemeToggle: () => void;
children: ReactNode;
recentMessages?: MessageRecord[];
guildId?: string;
channelId?: string;
notificationCount?: number;
}
export function DashboardLayout({
activeTab,
wsStatus,
voiceStatus,
themeMode,
isDark,
onTabChange,
onThemeToggle,
children,
recentMessages = [],
guildId,
channelId,
notificationCount = 0,
}: DashboardLayoutProps) {
return (
<div className="relative min-h-screen bg-background text-foreground">
{/* Background layers */}
<ParticleBackground />
<div
className="fixed inset-0 pointer-events-none grid-pattern opacity-[0.03]"
aria-hidden="true"
/>
<div className="relative flex min-h-screen">
<Sidebar
activeTab={activeTab}
onTabChange={onTabChange}
recentMessages={recentMessages}
guildId={guildId}
channelId={channelId}
notificationCount={notificationCount}
/>
<main className="flex min-w-0 flex-1 flex-col">
<Header
activeTab={activeTab}
wsStatus={wsStatus}
voiceStatus={voiceStatus}
themeMode={themeMode}
isDark={isDark}
onThemeToggle={onThemeToggle}
/>
<AnimatePresence mode="wait">
<motion.main
key={activeTab}
variants={fadeSlideUp}
initial="initial"
animate="animate"
exit="exit"
className="flex-1 overflow-auto p-4 md:p-6 lg:p-8 pb-16 md:pb-0"
>
{children}
</motion.main>
</AnimatePresence>
</main>
</div>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { motion } from "framer-motion";
import { Moon, Sun, Wifi, WifiOff } from "lucide-react";
import type { DashboardTab } from "../entities/ui/types.js";
import type { VoiceStatus } from "../entities/voice/types.js";
import type { ThemeMode } from "../hooks/useTheme";
import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
import { cn } from "../shared/lib/utils";
import { Badge } from "../shared/ui";
import type { WsStatus } from "../shared/ws/socket";
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>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { motion } from "framer-motion";
import {
Bell,
LayoutDashboard,
MessageSquare,
Radio,
Settings,
} from "lucide-react";
import type { MessageRecord } from "../entities/message/types.js";
import type { DashboardTab } from "../entities/ui/types.js";
import { useMascotChat } from "../shared/hooks/useMascotChat";
import { cn } from "../shared/lib/utils";
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,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 "../../shared/lib/logger.js";
import { cn } from "../../shared/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,110 @@
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,36 @@
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>
);
}