From 9f454b57bb02fde70fa3ea75c37f7f8834ec018c Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Wed, 3 Jun 2026 14:17:21 +0700 Subject: [PATCH] feat(mascot): add AI-powered chat insights to sidebar mascot with useMascotSummary hook --- services/frontend/src/App.tsx | 1 + services/frontend/src/features/auth/index.tsx | 4 +- .../src/shared/hooks/useMascotSummary.ts | 125 +++ .../frontend/src/widgets/DashboardLayout.tsx | 15 +- services/frontend/src/widgets/Sidebar.tsx | 18 +- .../src/widgets/mascot/ChibiMascot.tsx | 895 ------------------ .../src/widgets/mascot/MascotImage.tsx | 70 +- 7 files changed, 221 insertions(+), 907 deletions(-) create mode 100644 services/frontend/src/shared/hooks/useMascotSummary.ts delete mode 100644 services/frontend/src/widgets/mascot/ChibiMascot.tsx diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index c7d554e..c1821ba 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -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 ? ( diff --git a/services/frontend/src/features/auth/index.tsx b/services/frontend/src/features/auth/index.tsx index cfadf5c..a566a31 100644 --- a/services/frontend/src/features/auth/index.tsx +++ b/services/frontend/src/features/auth/index.tsx @@ -10,7 +10,6 @@ import { CardHeader, CardTitle, Input, - MascotImage, } from "../../shared/ui"; interface AuthOverlayProps { @@ -46,11 +45,10 @@ export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
-
+
-
Admin Access Required diff --git a/services/frontend/src/shared/hooks/useMascotSummary.ts b/services/frontend/src/shared/hooks/useMascotSummary.ts new file mode 100644 index 0000000..3c6174c --- /dev/null +++ b/services/frontend/src/shared/hooks/useMascotSummary.ts @@ -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(""); + + 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; +} diff --git a/services/frontend/src/widgets/DashboardLayout.tsx b/services/frontend/src/widgets/DashboardLayout.tsx index 49a9fba..f4fd9a4 100644 --- a/services/frontend/src/widgets/DashboardLayout.tsx +++ b/services/frontend/src/widgets/DashboardLayout.tsx @@ -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 (
{/* Sakura particle layer */}
- +
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 ( - {/* Mascot PNG */} + {/* Mascot PNG with chat bubble */}
- +
); diff --git a/services/frontend/src/widgets/mascot/ChibiMascot.tsx b/services/frontend/src/widgets/mascot/ChibiMascot.tsx deleted file mode 100644 index 77a03dc..0000000 --- a/services/frontend/src/widgets/mascot/ChibiMascot.tsx +++ /dev/null @@ -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 = { - 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 ( - <> - -
- {/* Inner container — everything is positioned relative to this */} -
- {/* ─── PEEKING variant: a surface to peek over ─── */} - {variant === "peeking" && ( -
- {/* Surface highlight */} -
-
- )} - - {/* ─── Body ─── */} -
- {/* dress/collar detail */} -
-
- - {/* ─── Arms ─── */} - {/* Left arm (always visible, resting) */} -
- - {/* Right arm — varies by variant */} - {variant === "waving" && ( -
- )} - - {variant === "thinking" && ( -
- {/* tiny hand */} -
-
- )} - - {variant === "crying" && ( -
- )} - - {variant === "idle" || - variant === "sleeping" || - variant === "peeking" ? ( -
- ) : null} - - {/* ─── Head (circle) ─── */} -
- {/* ─── SKIN SHADOW (subtle) ─── */} -
- - {/* ─── EARS ─── */} - {/* Left ear */} -
- {/* Inner ear */} -
-
- - {/* Right ear */} -
- {/* Inner ear */} -
-
- - {/* ─── HAIR BANG ─── */} -
- {/* Hair shine */} -
- {/* Side wisps */} -
-
-
- - {/* ─── EYES ─── */} - {(variant === "sleeping" || variant === "crying") && ( - <> - {/* Closed / squeezed eyes */} -
-
- - )} - - {variant !== "sleeping" && variant !== "crying" && ( - <> - {/* Normal big eyes */} -
- {/* Pupil */} -
- {/* Highlight dot */} -
- {/* Small secondary highlight */} -
-
-
- {/* Pupil */} -
- {/* Highlight dot */} -
- {/* Small secondary highlight */} -
-
- - )} - - {/* ─── NOSE (pink) ─── */} -
- - {/* ─── MOUTH ─── */} -
- - {/* ─── BLUSH ─── */} -
-
- - {/* ─── CRYING: Teardrops ─── */} - {variant === "crying" && ( - <> -
-
- - )} -
- - {/* ─── VARIANT EXTRAS (outside head) ─── */} - - {/* Thinking dots */} - {variant === "thinking" && ( - <> -
-
-
- - )} - - {/* Sleeping zzz */} - {variant === "sleeping" && ( - <> -
- z -
-
- z -
-
- Z -
- - )} -
-
- - ); -} - -// ─── 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 ( -
- -

{message}

- {action && ( - - )} -
- ); -} diff --git a/services/frontend/src/widgets/mascot/MascotImage.tsx b/services/frontend/src/widgets/mascot/MascotImage.tsx index 1615896..ad966d8 100644 --- a/services/frontend/src/widgets/mascot/MascotImage.tsx +++ b/services/frontend/src/widgets/mascot/MascotImage.tsx @@ -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 ( - Mascot +
+ + + {/* Floating Chat Bubble */} + {isVisible && chatMessage && ( + +
+ {/* Chat bubble */} +
+
+ +

+ {chatMessage} +

+
+ + {/* Chat bubble tail */} +
+
+
+ + )} +
); }