feat(mascot): add AI-powered chat insights to sidebar mascot with useMascotSummary hook
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user