feat(mascot): add AI-powered chat insights to sidebar mascot with useMascotSummary hook

This commit is contained in:
MythEclipse
2026-06-03 14:17:21 +07:00
parent bb65178210
commit 9f454b57bb
7 changed files with 221 additions and 907 deletions
+1
View File
@@ -162,6 +162,7 @@ export default function App() {
wsStatus={socket.status}
voiceStatus={voice.voiceStatus}
onTabChange={(tab) => patchUIState({ activeTab: tab })}
recentMessages={messages.messages}
>
{activeTab === "live" ? (
!isAuthenticated ? (
@@ -10,7 +10,6 @@ import {
CardHeader,
CardTitle,
Input,
MascotImage,
} from "../../shared/ui";
interface AuthOverlayProps {
@@ -46,11 +45,10 @@ export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
<div ref={pageRef} className="flex 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 gap-3">
<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">
<Lock className="h-6 w-6" />
</div>
<MascotImage size="sm" />
</div>
<CardTitle>Admin Access Required</CardTitle>
<CardDescription>
@@ -0,0 +1,125 @@
import { useEffect, useState } from "react";
import type { MessageRecord } from "../api/client";
/**
* useMascotSummary — Generates AI-powered summary/insights from recent messages
* Used by mascot's floating chat bubble to display conversation insights
*/
interface UseMascotSummaryOptions {
messages: MessageRecord[];
enabled?: boolean;
}
const summaryPrompts = [
"📊 Diskusi sangat aktif dengan {count} pesan",
"💬 Topik populer: {topic} ({percentage}%)",
"👥 Partisipan utama: {users}",
"⏰ Aktivitas puncak: {time}",
"🔥 Buzz level: {level}",
"💡 Insight: {insight}",
];
function generateInsight(messages: MessageRecord[]): string {
if (messages.length === 0) {
return "Menunggu pesan...";
}
const totalMessages = messages.length;
const recentMessages = messages.slice(-10);
// Hitung user yang berbeda
const uniqueUsers = new Set(recentMessages.map((m) => m.user_id)).size;
// Hitung average panjang pesan
const avgLength = Math.round(
recentMessages.reduce((sum, m) => sum + (m.content?.length || 0), 0) /
recentMessages.length
);
// Tentukan tipe percakapan
let insight = "";
if (avgLength > 150) {
insight = "Diskusi mendalam sedang berlangsung";
} else if (avgLength > 80) {
insight = "Percakapan normal dan interaktif";
} else {
insight = "Chat cepat dan ringkas";
}
// Tambah info partisipan
if (uniqueUsers > 5) {
insight += `${uniqueUsers} orang aktif`;
}
// Tambah info volume
if (totalMessages > 50) {
insight += " • Volume tinggi 🔥";
} else if (totalMessages > 20) {
insight += " • Percakapan aktif";
}
return insight;
}
function extractTopics(messages: MessageRecord[]): string {
if (messages.length === 0) return "Tidak ada topik";
// Extract keywords dari recent messages
const recentMessages = messages.slice(-15);
const content = recentMessages
.map((m) => m.content?.toLowerCase() || "")
.join(" ");
// Simple keyword extraction
const keywords = [
{ word: "voice", label: "Voice" },
{ word: "recording", label: "Recording" },
{ word: "audio", label: "Audio" },
{ word: "chat", label: "Chat" },
{ word: "message", label: "Message" },
{ word: "user", label: "User" },
];
for (const { word, label } of keywords) {
if (content.includes(word)) {
return label;
}
}
return "Umum";
}
export function useMascotSummary({
messages,
enabled = true,
}: UseMascotSummaryOptions): string {
const [summary, setSummary] = useState<string>("");
useEffect(() => {
if (!enabled || messages.length === 0) {
setSummary("");
return;
}
// Generate summary berdasarkan messages
const insight = generateInsight(messages);
setSummary(insight);
// Rotate summary setiap 5 detik
const interval = setInterval(() => {
setSummary((prev) => {
if (prev.includes("aktif")) {
return `📈 Total: ${messages.length} pesan`;
} else if (prev.includes("Total")) {
return generateInsight(messages);
}
return prev;
});
}, 5000);
return () => clearInterval(interval);
}, [messages, enabled]);
return summary;
}
@@ -1,8 +1,10 @@
import { motion } from "framer-motion";
import type { ReactNode } from "react";
import { useMemo } from "react";
import type { DashboardTab } from "../entities/ui/types";
import type { VoiceStatus } from "../shared/api/client";
import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
import { useMascotSummary } from "../shared/hooks/useMascotSummary";
import type { WsStatus } from "../shared/ws/socket";
import { Header } from "./Header";
import { ParticleBackground } from "./particles/ParticleBackground";
@@ -14,6 +16,7 @@ interface DashboardLayoutProps {
voiceStatus: VoiceStatus;
onTabChange: (tab: DashboardTab) => void;
children: ReactNode;
recentMessages?: any[];
}
export function DashboardLayout({
@@ -22,14 +25,24 @@ export function DashboardLayout({
voiceStatus,
onTabChange,
children,
recentMessages = [],
}: DashboardLayoutProps) {
// Generate mascot summary from recent messages
const mascotSummary = useMascotSummary({
messages: recentMessages,
enabled: activeTab === "messages" && recentMessages.length > 0,
});
return (
<div className="relative min-h-screen bg-background text-foreground">
{/* Sakura particle layer */}
<ParticleBackground />
<div className="relative flex min-h-screen">
<Sidebar activeTab={activeTab} onTabChange={onTabChange} />
<Sidebar
activeTab={activeTab}
onTabChange={onTabChange}
mascotChatMessage={mascotSummary}
/>
<main className="flex min-w-0 flex-1 flex-col">
<Header
activeTab={activeTab}
+16 -2
View File
@@ -1,5 +1,6 @@
import { motion } from "framer-motion";
import { BarChart3, MessageSquare, Radio } from "lucide-react";
import { useEffect, useState } from "react";
import type { DashboardTab } from "../entities/ui/types";
import { cn } from "../shared/lib/utils";
import { MascotImage } from "./mascot/MascotImage";
@@ -15,13 +16,22 @@ interface SidebarProps {
activeTab: DashboardTab;
onTabChange: (tab: DashboardTab) => void;
collapsed?: boolean;
mascotChatMessage?: string;
}
export function Sidebar({
activeTab,
onTabChange,
collapsed = true,
mascotChatMessage = "",
}: SidebarProps) {
const [showChat, setShowChat] = useState(false);
useEffect(() => {
if (mascotChatMessage) {
setShowChat(true);
}
}, [mascotChatMessage]);
return (
<motion.nav
className={cn(
@@ -84,9 +94,13 @@ export function Sidebar({
{/* Spacer pushes mascot to bottom */}
<div className="flex-1" />
{/* Mascot PNG */}
{/* Mascot PNG with chat bubble */}
<div className="flex justify-center pb-4">
<MascotImage size="sm" />
<MascotImage
size="sm"
showChat={showChat && !collapsed}
chatMessage={mascotChatMessage}
/>
</div>
</motion.nav>
);
@@ -1,895 +0,0 @@
import { cn } from "../../shared/lib/utils";
// ─── Types ───────────────────────────────────────────────────────────────────
type MascotVariant =
| "idle"
| "waving"
| "sleeping"
| "thinking"
| "peeking"
| "crying";
interface ChibiMascotProps {
variant?: MascotVariant;
size?: "sm" | "md" | "lg";
className?: string;
}
// ─── Size map ────────────────────────────────────────────────────────────────
const sizes = { sm: 48, md: 80, lg: 120 } as const;
// ─── Keyframes ───────────────────────────────────────────────────────────────
const keyframes = `
@keyframes cb-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
@keyframes cb-wave {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(18deg); }
75% { transform: rotate(-8deg); }
}
@keyframes cb-sleep {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(0.94); }
}
@keyframes cb-think {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-3px) rotate(4deg); }
}
@keyframes cb-cry {
0%, 100% { transform: translateY(0) scale(1); }
25% { transform: translateY(-2px) scale(1.03); }
75% { transform: translateY(2px) scale(0.97); }
}
@keyframes cb-peek {
0%, 100% { transform: translateX(0) rotate(0deg); }
50% { transform: translateX(-4px) rotate(-4deg); }
}
@keyframes cb-teardrop {
0% { opacity: 1; transform: translateY(0) scaleX(1); }
100% { opacity: 0; transform: translateY(14px) scaleX(0.4); }
}
@keyframes cb-blink {
0%, 85%, 100% { transform: scaleY(1); }
90% { transform: scaleY(0.08); }
}
@keyframes cb-zzz {
0% { opacity: 0; transform: translateX(0) translateY(0); }
40% { opacity: 1; transform: translateX(5px) translateY(-5px); }
100% { opacity: 0; transform: translateX(14px) translateY(-14px); }
}
@keyframes cb-dots {
0% { opacity: 0; transform: scale(0.6); }
50% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(0.6); }
}
@keyframes cb-tear-wobble {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-2px); }
75% { transform: translateX(2px); }
}
@keyframes cb-ear-twitch {
0%, 100% { transform: rotate(0deg); }
20% { transform: rotate(12deg); }
40% { transform: rotate(-6deg); }
60% { transform: rotate(8deg); }
}
@keyframes cb-arm-chin {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(6deg); }
}
`;
// ─── Variant animation styles ────────────────────────────────────────────────
function getAnimation(variant: MascotVariant): React.CSSProperties {
const map: Record<MascotVariant, string> = {
idle: "cb-bob 2.5s ease-in-out infinite",
waving: "cb-bob 2.5s ease-in-out infinite",
sleeping: "cb-sleep 3.5s ease-in-out infinite",
thinking: "cb-think 2.2s ease-in-out infinite",
peeking: "cb-peek 2.4s ease-in-out infinite",
crying: "cb-cry 1.4s ease-in-out infinite",
};
return { animation: map[variant] };
}
// ─── Component ───────────────────────────────────────────────────────────────
export function ChibiMascot({
variant = "idle",
size = "md",
className,
}: ChibiMascotProps) {
const px = sizes[size];
// Base design dimension is 80px, we scale everything from 0-100 percentage canvas
const ch = (pct: number) => (pct / 100) * px;
// Colors
const skin = "#FFE4D6";
const skinShadow = "#F5D5C3";
const hair = "#FFB7C5";
const hairDark = "#F59CB0";
const eyeColor = "#7EC8E3";
const eyeShine = "#FFFFFF";
const blush = "#FFB7C5";
const outfit = "#7EC8E3";
const outfitDark = "#6BB5D0";
const outline = "#E8C4C9";
return (
<>
<style>{keyframes}</style>
<div
className={cn(
"relative inline-flex items-center justify-center overflow-visible",
className,
)}
style={{
width: px,
height: px,
...getAnimation(variant),
}}
aria-label={`Chibi mascot (${variant})`}
role="img"
>
{/* Inner container — everything is positioned relative to this */}
<div
style={{
position: "relative",
width: "100%",
height: "100%",
}}
>
{/* ─── PEEKING variant: a surface to peek over ─── */}
{variant === "peeking" && (
<div
key="peek-surface"
style={{
position: "absolute",
bottom: "28%",
left: "-10%",
width: "120%",
height: "38%",
background: "linear-gradient(180deg, #E0F2FE 0%, #BAE6FD 100%)",
borderRadius: "30% 30% 0 0",
border: `2px solid #7DD3FC`,
borderBottom: "none",
zIndex: 15,
boxShadow: "inset 0 -2px 4px rgba(0,0,0,0.06)",
}}
>
{/* Surface highlight */}
<div
style={{
position: "absolute",
top: "20%",
left: "15%",
width: "70%",
height: "30%",
background: "rgba(255,255,255,0.4)",
borderRadius: "50%",
}}
/>
</div>
)}
{/* ─── Body ─── */}
<div
key="body"
style={{
position: "absolute",
bottom: "18%",
left: "50%",
marginLeft: ch(-20),
width: ch(40),
height: ch(28),
background: `linear-gradient(180deg, ${outfit} 0%, ${outfitDark} 100%)`,
borderRadius: `${ch(8)} ${ch(8)} ${ch(5)} ${ch(5)}`,
border: `2px solid ${outline}`,
zIndex: 1,
}}
>
{/* dress/collar detail */}
<div
style={{
position: "absolute",
top: "0%",
left: "10%",
width: "80%",
height: "30%",
background: "rgba(255,255,255,0.15)",
borderRadius: "0 0 50% 50%",
}}
/>
</div>
{/* ─── Arms ─── */}
{/* Left arm (always visible, resting) */}
<div
key="arm-left"
style={{
position: "absolute",
bottom: "24%",
left: "50%",
marginLeft: ch(-38),
width: ch(7),
height: ch(18),
background: `linear-gradient(180deg, ${skinShadow} 0%, ${skin} 100%)`,
borderRadius: ch(4),
border: `1.5px solid ${outline}`,
transformOrigin: "bottom center",
transform: "rotate(12deg)",
zIndex: 2,
}}
/>
{/* Right arm — varies by variant */}
{variant === "waving" && (
<div
key="arm-right-waving"
style={{
position: "absolute",
bottom: "26%",
right: "50%",
marginRight: ch(-36),
width: ch(7),
height: ch(18),
background: `linear-gradient(180deg, ${skin} 0%, ${skinShadow} 100%)`,
borderRadius: ch(4),
border: `1.5px solid ${outline}`,
transformOrigin: "bottom center",
animation: "cb-wave 0.5s ease-in-out infinite",
zIndex: 2,
}}
/>
)}
{variant === "thinking" && (
<div
key="arm-right-thinking"
style={{
position: "absolute",
bottom: "36%",
right: "50%",
marginRight: ch(-32),
width: ch(6),
height: ch(15),
background: `linear-gradient(180deg, ${skin} 0%, ${skinShadow} 100%)`,
borderRadius: ch(3),
border: `1.5px solid ${outline}`,
transformOrigin: "bottom center",
transform: "rotate(-20deg)",
animation: "cb-arm-chin 2.2s ease-in-out infinite",
zIndex: 2,
}}
>
{/* tiny hand */}
<div
style={{
position: "absolute",
top: -ch(2),
left: "50%",
marginLeft: ch(-2.5),
width: ch(5),
height: ch(4),
background: skin,
borderRadius: "50%",
border: `1px solid ${outline}`,
}}
/>
</div>
)}
{variant === "crying" && (
<div
key="arm-right-crying"
style={{
position: "absolute",
bottom: "24%",
right: "50%",
marginRight: ch(-36),
width: ch(7),
height: ch(16),
background: `linear-gradient(180deg, ${skin} 0%, ${skinShadow} 100%)`,
borderRadius: ch(4),
border: `1.5px solid ${outline}`,
transformOrigin: "bottom center",
transform: "rotate(-8deg)",
animation: "cb-tear-wobble 1.4s ease-in-out infinite",
zIndex: 2,
}}
/>
)}
{variant === "idle" ||
variant === "sleeping" ||
variant === "peeking" ? (
<div
key="arm-right-rest"
style={{
position: "absolute",
bottom: "24%",
right: "50%",
marginRight: ch(-36),
width: ch(7),
height: ch(16),
background: `linear-gradient(180deg, ${skin} 0%, ${skinShadow} 100%)`,
borderRadius: ch(4),
border: `1.5px solid ${outline}`,
transformOrigin: "bottom center",
transform: "rotate(-8deg)",
zIndex: 2,
}}
/>
) : null}
{/* ─── Head (circle) ─── */}
<div
key="head"
style={{
position: "absolute",
top: "10%",
left: "50%",
marginLeft: ch(-30),
width: ch(60),
height: ch(60),
background: skin,
borderRadius: "50%",
border: `2.5px solid ${outline}`,
zIndex: 3,
overflow: "visible",
}}
>
{/* ─── SKIN SHADOW (subtle) ─── */}
<div
style={{
position: "absolute",
bottom: 0,
left: "15%",
width: "70%",
height: "25%",
background: skinShadow,
borderRadius: "0 0 50% 50%",
opacity: 0.4,
zIndex: 0,
}}
/>
{/* ─── EARS ─── */}
{/* Left ear */}
<div
key="ear-left"
style={{
position: "absolute",
top: ch(-5),
left: ch(-7),
width: 0,
height: 0,
borderLeft: `${ch(6)}px solid transparent`,
borderRight: `${ch(6)}px solid transparent`,
borderBottom: `${ch(14)}px solid ${hair}`,
zIndex: 4,
filter: `drop-shadow(0 1px 0 ${outline})`,
transformOrigin: "bottom center",
...(variant === "waving"
? { animation: "cb-ear-twitch 1.2s ease-in-out infinite" }
: {}),
}}
>
{/* Inner ear */}
<div
style={{
position: "absolute",
top: ch(2),
left: ch(-3.5),
width: 0,
height: 0,
borderLeft: `${ch(3.5)}px solid transparent`,
borderRight: `${ch(3.5)}px solid transparent`,
borderBottom: `${ch(8)}px solid #FFB7C5`,
zIndex: 5,
}}
/>
</div>
{/* Right ear */}
<div
key="ear-right"
style={{
position: "absolute",
top: ch(-5),
right: ch(-7),
width: 0,
height: 0,
borderLeft: `${ch(6)}px solid transparent`,
borderRight: `${ch(6)}px solid transparent`,
borderBottom: `${ch(14)}px solid ${hair}`,
zIndex: 4,
filter: `drop-shadow(0 1px 0 ${outline})`,
transformOrigin: "bottom center",
...(variant === "waving"
? {
animation:
"cb-ear-twitch 1.2s 0.15s ease-in-out infinite",
}
: {}),
}}
>
{/* Inner ear */}
<div
style={{
position: "absolute",
top: ch(2),
left: ch(-3.5),
width: 0,
height: 0,
borderLeft: `${ch(3.5)}px solid transparent`,
borderRight: `${ch(3.5)}px solid transparent`,
borderBottom: `${ch(8)}px solid #FFB7C5`,
zIndex: 5,
}}
/>
</div>
{/* ─── HAIR BANG ─── */}
<div
key="hair-bang"
style={{
position: "absolute",
top: ch(-1),
left: "12%",
width: "76%",
height: "36%",
background: hair,
borderRadius: "50% 50% 20% 20%",
border: `1.5px solid ${hairDark}`,
zIndex: 6,
overflow: "hidden",
}}
>
{/* Hair shine */}
<div
style={{
position: "absolute",
top: "15%",
left: "20%",
width: "35%",
height: "40%",
background: "rgba(255,255,255,0.3)",
borderRadius: "50%",
transform: "rotate(-15deg)",
}}
/>
{/* Side wisps */}
<div
style={{
position: "absolute",
bottom: "10%",
left: "-5%",
width: "30%",
height: "50%",
background: hair,
borderRadius: "0 0 50% 50%",
borderLeft: `1px solid ${hairDark}`,
}}
/>
<div
style={{
position: "absolute",
bottom: "10%",
right: "-5%",
width: "30%",
height: "50%",
background: hair,
borderRadius: "0 0 50% 50%",
borderRight: `1px solid ${hairDark}`,
}}
/>
</div>
{/* ─── EYES ─── */}
{(variant === "sleeping" || variant === "crying") && (
<>
{/* Closed / squeezed eyes */}
<div
key="eye-left"
style={{
position: "absolute",
top: "40%",
left: "22%",
width: "24%",
height: variant === "sleeping" ? "4%" : "8%",
background: "#555",
borderRadius: variant === "sleeping" ? "2px" : "50%",
zIndex: 7,
animation:
variant === "sleeping"
? "cb-sleep 3.5s ease-in-out infinite"
: "cb-blink 3s 2s infinite",
}}
/>
<div
key="eye-right"
style={{
position: "absolute",
top: "40%",
right: "22%",
width: "24%",
height: variant === "sleeping" ? "4%" : "8%",
background: "#555",
borderRadius: variant === "sleeping" ? "2px" : "50%",
zIndex: 7,
animation:
variant === "sleeping"
? "cb-sleep 3.5s ease-in-out infinite"
: "cb-blink 3s 2s infinite",
}}
/>
</>
)}
{variant !== "sleeping" && variant !== "crying" && (
<>
{/* Normal big eyes */}
<div
key="eye-left"
style={{
position: "absolute",
top: "38%",
left: "20%",
width: "26%",
height: "34%",
background: eyeColor,
borderRadius: "50%",
border: `1.5px solid #555`,
zIndex: 7,
animation: "cb-blink 4s 1s infinite",
overflow: "hidden",
}}
>
{/* Pupil */}
<div
style={{
position: "absolute",
bottom: "10%",
left: "20%",
width: "60%",
height: "60%",
background: "#5BA3C7",
borderRadius: "50%",
}}
/>
{/* Highlight dot */}
<div
style={{
position: "absolute",
top: "15%",
left: "20%",
width: "35%",
height: "35%",
background: eyeShine,
borderRadius: "50%",
}}
/>
{/* Small secondary highlight */}
<div
style={{
position: "absolute",
bottom: "25%",
right: "15%",
width: "15%",
height: "15%",
background: "rgba(255,255,255,0.6)",
borderRadius: "50%",
}}
/>
</div>
<div
key="eye-right"
style={{
position: "absolute",
top: "38%",
right: "20%",
width: "26%",
height: "34%",
background: eyeColor,
borderRadius: "50%",
border: `1.5px solid #555`,
zIndex: 7,
animation: "cb-blink 4s 1s infinite",
overflow: "hidden",
}}
>
{/* Pupil */}
<div
style={{
position: "absolute",
bottom: "10%",
left: "20%",
width: "60%",
height: "60%",
background: "#5BA3C7",
borderRadius: "50%",
}}
/>
{/* Highlight dot */}
<div
style={{
position: "absolute",
top: "15%",
left: "20%",
width: "35%",
height: "35%",
background: eyeShine,
borderRadius: "50%",
}}
/>
{/* Small secondary highlight */}
<div
style={{
position: "absolute",
bottom: "25%",
right: "15%",
width: "15%",
height: "15%",
background: "rgba(255,255,255,0.6)",
borderRadius: "50%",
}}
/>
</div>
</>
)}
{/* ─── NOSE (pink) ─── */}
<div
key="nose"
style={{
position: "absolute",
top: "58%",
left: "50%",
marginLeft: ch(-1.5),
width: ch(3),
height: ch(2.2),
background: "#FF9EBB",
borderRadius: "50% 50% 50% 50% / 60% 60% 40% 40%",
zIndex: 9,
}}
/>
{/* ─── MOUTH ─── */}
<div
key="mouth"
style={{
position: "absolute",
top: "64%",
left: "50%",
marginLeft: ch(-3),
width: ch(6),
height: ch(2.5),
borderBottom: `2px solid ${outline}`,
borderLeft: "1px solid transparent",
borderRight: "1px solid transparent",
borderRadius: "0 0 50% 50%",
zIndex: 8,
}}
/>
{/* ─── BLUSH ─── */}
<div
key="blush-left"
style={{
position: "absolute",
top: "52%",
left: "6%",
width: "16%",
height: "12%",
background: blush,
borderRadius: "50%",
opacity: 0.45,
zIndex: 7,
}}
/>
<div
key="blush-right"
style={{
position: "absolute",
top: "52%",
right: "6%",
width: "16%",
height: "12%",
background: blush,
borderRadius: "50%",
opacity: 0.45,
zIndex: 7,
}}
/>
{/* ─── CRYING: Teardrops ─── */}
{variant === "crying" && (
<>
<div
key="tear-left"
style={{
position: "absolute",
top: "70%",
left: "14%",
width: "6%",
height: "12%",
background:
"linear-gradient(180deg, rgba(137,207,240,0.9) 0%, rgba(137,207,240,0.3) 100%)",
borderRadius: "50% 50% 50% 50% / 60% 60% 40% 40%",
animation: "cb-teardrop 1.6s ease-in infinite",
zIndex: 10,
}}
/>
<div
key="tear-right"
style={{
position: "absolute",
top: "72%",
right: "14%",
width: "6%",
height: "12%",
background:
"linear-gradient(180deg, rgba(137,207,240,0.9) 0%, rgba(137,207,240,0.3) 100%)",
borderRadius: "50% 50% 50% 50% / 60% 60% 40% 40%",
animation: "cb-teardrop 1.6s 0.3s ease-in infinite",
zIndex: 10,
}}
/>
</>
)}
</div>
{/* ─── VARIANT EXTRAS (outside head) ─── */}
{/* Thinking dots */}
{variant === "thinking" && (
<>
<div
key="think-dot1"
style={{
position: "absolute",
top: "4%",
right: "12%",
width: ch(3.5),
height: ch(3.5),
background: "#A0A0A0",
borderRadius: "50%",
animation: "cb-dots 1.6s ease-in-out infinite",
zIndex: 10,
}}
/>
<div
key="think-dot2"
style={{
position: "absolute",
top: "-1%",
right: "6%",
width: ch(4.5),
height: ch(4.5),
background: "#909090",
borderRadius: "50%",
animation: "cb-dots 1.6s 0.3s ease-in-out infinite",
zIndex: 10,
}}
/>
<div
key="think-dot3"
style={{
position: "absolute",
top: "-7%",
right: "3%",
width: ch(5.5),
height: ch(5.5),
background: "#808080",
borderRadius: "50%",
animation: "cb-dots 1.6s 0.6s ease-in-out infinite",
zIndex: 10,
}}
/>
</>
)}
{/* Sleeping zzz */}
{variant === "sleeping" && (
<>
<div
key="zzz1"
style={{
position: "absolute",
top: "3%",
right: "6%",
fontSize: ch(8),
fontWeight: 700,
color: "#7EC8E3",
animation: "cb-zzz 2.8s ease-in-out infinite",
zIndex: 10,
fontFamily: "sans-serif",
lineHeight: 1,
}}
>
z
</div>
<div
key="zzz2"
style={{
position: "absolute",
top: "-4%",
right: "12%",
fontSize: ch(10),
fontWeight: 700,
color: "#7EC8E3",
animation: "cb-zzz 2.8s 0.5s ease-in-out infinite",
zIndex: 10,
fontFamily: "sans-serif",
lineHeight: 1,
}}
>
z
</div>
<div
key="zzz3"
style={{
position: "absolute",
top: "-12%",
right: "16%",
fontSize: ch(13),
fontWeight: 700,
color: "#5BA3C7",
animation: "cb-zzz 2.8s 1s ease-in-out infinite",
zIndex: 10,
fontFamily: "sans-serif",
lineHeight: 1,
}}
>
Z
</div>
</>
)}
</div>
</div>
</>
);
}
// ─── Empty state with mascot ─────────────────────────────────────────────────
interface EmptyStateMascotProps {
variant?: MascotVariant;
message: string;
action?: { label: string; onClick: () => void };
className?: string;
}
export function EmptyStateMascot({
variant = "idle",
message,
action,
className,
}: EmptyStateMascotProps) {
return (
<div
className={cn(
"flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-sky-200 bg-white/60 p-8 text-center",
className,
)}
>
<ChibiMascot variant={variant} size="md" />
<p className="text-sm text-muted-foreground">{message}</p>
{action && (
<button
onClick={action.onClick}
className="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-white shadow-sm transition-all hover:bg-primary/90 active:scale-95"
>
{action.label}
</button>
)}
</div>
);
}
@@ -1,11 +1,18 @@
import { motion } from "framer-motion";
import { MessageCircle } from "lucide-react";
import { useState, useEffect } 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;
}
const sizeMap = {
@@ -14,15 +21,66 @@ const sizeMap = {
lg: "w-48 h-auto",
};
export function MascotImage({ size = "md", className = "" }: MascotImageProps) {
const chatSizeMap = {
sm: "max-w-xs",
md: "max-w-sm",
lg: "max-w-md",
};
export function MascotImage({
size = "md",
className = "",
showChat = false,
chatMessage = "",
}: MascotImageProps) {
const sizeClass = sizeMap[size];
const chatSizeClass = chatSizeMap[size];
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (showChat && chatMessage) {
setIsVisible(true);
const timer = setTimeout(() => setIsVisible(false), 8000); // Auto hide after 8s
return () => clearTimeout(timer);
}
}, [showChat, chatMessage]);
return (
<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}`}
/>
<div className="relative inline-block">
<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 }}
/>
{/* 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-gradient-to-br from-primary/90 to-primary/80 text-white rounded-2xl px-4 py-2.5 shadow-lg backdrop-blur-sm border border-primary/50">
<div className="flex items-start gap-2">
<MessageCircle className="h-4 w-4 shrink-0 mt-0.5 text-white/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>
);
}