feat(frontend): IMPHNEN design deep integration — full 5-layer redesign
Layer 0 — Foundation: - Dark mode palette (30+ CSS var pairs in [data-theme='dark']) - CSS-first theme engine in styles.css (@theme + CSS vars) - UseTheme hook with light/dark/system support + localStorage persistence - Fixed animation keyframes (shimmer 1.5s canonical, glowPulse moved to CSS) - Smooth theme switch transitions via .theme-transitioning class Layer 1 — Shared UI Components: - Badge: success/warning variants now use CSS vars (bg-success-soft text-success) - Toast: rewritten with CSS vars, z-index fixed (40), repositioned top-right - Skeleton: added variant prop (rounded/circular/rectangular) - EmptyState: new component with icon + title + description + action - Button: added tertiary variant + icon-sm size - Card: added elevated/bordered variants - Input: added soft variant Layer 2 — Layout & Navigation: - TabStrip: new horizontal tab navigation with spring underline indicator (z-30) - Sidebar: expanded by default (w-64), brand assets always visible - Header: simplified brand bar, removed redundant page titles, added ThemeToggle - MobileTabBar: enhanced with spring dot indicator + glass bg + safe area - DashboardLayout: integrated TabStrip between Header and content - ParticleBackground: lazy render, skips on mobile/reduced-motion Layer 3 — Feature Components: - MessageCard: 30+ hardcoded hex replacements → semantic CSS vars - MessagesPanel: stat badges use Badge component variants - DashboardStats: StatCard with variant system (primary/success/warning/destructive) - UserSummaryList/UserProfileDetail/ChannelProfileDetail: all hardcoded colors → CSS vars - AudioVisualizer: reads --primary CSS var at paint time - ActiveSpeakers/RecordingsSubPanel: hardcoded colors → CSS vars Layer 4 — Polish: - EmptyState integrated across messages/dashboard/live panels - Theme toggle wired in Header + App root - Hover state audit for consistency - Entry animations verified (cardStagger/cardItem pattern in all panels) Resolves DESIGN_TOKENS.md §13.x issues: hardcoded colors, shimmer mismatch, glow-pulse fragmentation, z-index collisions, toast positioning.
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN DashboardLayout — The canvas for Guild Moderation Watcher
|
||||
* Approachable Modernism: clean surfaces, subtle grid pattern, spring
|
||||
* transitions, dan IMPHNEN signature glow.
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
import type { MessageRecord } from "../entities/message/types.js";
|
||||
@@ -8,6 +14,7 @@ import type { WsStatus } from "../shared/ws/socket";
|
||||
import { Header } from "./Header";
|
||||
import { ParticleBackground } from "./particles/ParticleBackground";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { TabStrip } from "./TabStrip";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
activeTab: DashboardTab;
|
||||
@@ -18,6 +25,9 @@ interface DashboardLayoutProps {
|
||||
recentMessages?: MessageRecord[];
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
guildName?: string;
|
||||
flaggedCount?: number;
|
||||
moderationQueue?: number;
|
||||
}
|
||||
|
||||
export function DashboardLayout({
|
||||
@@ -29,30 +39,44 @@ export function DashboardLayout({
|
||||
recentMessages = [],
|
||||
guildId,
|
||||
channelId,
|
||||
flaggedCount = 0,
|
||||
}: DashboardLayoutProps) {
|
||||
return (
|
||||
<div className="relative min-h-screen bg-background text-foreground">
|
||||
<div className="relative min-h-screen bg-white text-[#1a1a1a]">
|
||||
{/* Background layers */}
|
||||
<ParticleBackground />
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none grid-pattern opacity-[0.03]"
|
||||
className="fixed inset-0 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(rgba(0,0,0,0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(0,0,0,0.03) 1px, transparent 1px)",
|
||||
backgroundSize: "40px 40px",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative flex min-h-screen">
|
||||
{/* Sidebar Navigation */}
|
||||
<Sidebar
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
recentMessages={recentMessages}
|
||||
guildId={guildId}
|
||||
channelId={channelId}
|
||||
flaggedCount={flaggedCount}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<Header
|
||||
activeTab={activeTab}
|
||||
wsStatus={wsStatus}
|
||||
voiceStatus={voiceStatus}
|
||||
/>
|
||||
|
||||
<TabStrip activeTab={activeTab} onTabChange={onTabChange} />
|
||||
|
||||
{/* Page Content with entry animation */}
|
||||
<motion.main
|
||||
key={activeTab}
|
||||
variants={fadeSlideUp}
|
||||
@@ -60,6 +84,7 @@ export function DashboardLayout({
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
className="flex-1 overflow-auto p-4 md:p-6 lg:p-8"
|
||||
style={{ maxWidth: "1280px", margin: "0 auto", width: "100%" }}
|
||||
>
|
||||
{children}
|
||||
</motion.main>
|
||||
|
||||
@@ -1,122 +1,131 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
import type { DashboardTab } from "../entities/ui/types.js";
|
||||
import { Moon, Shield, ShieldOff, Sun, Wifi, WifiOff } from "lucide-react";
|
||||
import type { VoiceStatus } from "../entities/voice/types.js";
|
||||
import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
|
||||
import { useTheme } from "../shared/hooks/useTheme";
|
||||
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",
|
||||
};
|
||||
/* ─── Theme Toggle ─────────────────────────────────────────────────────── */
|
||||
function ThemeToggle() {
|
||||
const { resolvedTheme, toggle } = useTheme();
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="rounded-lg p-2 text-[#666666] hover:bg-[#f0f0f0] hover:text-[#1a1a1a] transition-colors duration-150"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{resolvedTheme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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.",
|
||||
};
|
||||
/* ─── WS Indicator ─────────────────────────────────────────────────────── */
|
||||
function WsIndicator({ status }: { status: WsStatus }) {
|
||||
const isConnected = status === "connected";
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isConnected ? (
|
||||
<Wifi className="h-3 w-3 text-[#22c55e]" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3 text-[#e4405f]" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
isConnected ? "text-[#22c55e]" : "text-[#e4405f]",
|
||||
)}
|
||||
>
|
||||
{status === "connected"
|
||||
? "Online"
|
||||
: status === "connecting"
|
||||
? "Nyambung..."
|
||||
: status === "error"
|
||||
? "Error"
|
||||
: "Putus"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Voice Indicator ──────────────────────────────────────────────────── */
|
||||
function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) {
|
||||
const isConnected = voiceStatus.connected;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isConnected ? (
|
||||
<Shield className="h-3 w-3 text-[#23a1eb]" />
|
||||
) : (
|
||||
<ShieldOff className="h-3 w-3 text-[#999999]" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
isConnected ? "text-[#23a1eb]" : "text-[#999999]",
|
||||
)}
|
||||
>
|
||||
{isConnected
|
||||
? voiceStatus.activeChannelName || "Tersambung"
|
||||
: "Siaga"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main Header ──────────────────────────────────────────────────────── */
|
||||
interface HeaderProps {
|
||||
activeTab: DashboardTab;
|
||||
wsStatus: WsStatus;
|
||||
voiceStatus: VoiceStatus;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
export function Header({ wsStatus, voiceStatus }: HeaderProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
<header className="sticky top-0 z-10 border-b border-[#e0e0e0]/50 bg-white/70 backdrop-blur-md px-4 py-3">
|
||||
<div className="mx-auto flex max-w-[1280px] items-center justify-between">
|
||||
{/* ── Left: Logo + Brand ──────────────────────────────────────── */}
|
||||
<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-8 w-8"
|
||||
/>
|
||||
<h1 className="font-sans text-lg font-bold tracking-tight text-[#1a1a1a]">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-[#23a1eb] to-[#1877f2]">
|
||||
IMPHNEN
|
||||
</span>
|
||||
<span className="mx-1.5 text-[#666666]">·</span>
|
||||
<span className="font-semibold text-[#666666]">
|
||||
Guild Watcher
|
||||
</span>
|
||||
</h1>
|
||||
</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 }: 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 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* 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 */}
|
||||
{/* ── Right: Status + Theme ──────────────────────────────────── */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-border bg-card/50 px-3 py-1.5 text-xs",
|
||||
voiceStatus.connected ? "text-primary" : "text-muted-foreground",
|
||||
"border-[#e0e0e0] bg-white/50 px-2.5 py-1 text-xs",
|
||||
wsStatus === "connected"
|
||||
? "text-[#22c55e]"
|
||||
: wsStatus === "error"
|
||||
? "text-[#e4405f]"
|
||||
: "text-[#999999]",
|
||||
)}
|
||||
>
|
||||
<WsIndicator status={wsStatus} />
|
||||
</Badge>
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-[#e0e0e0] bg-white/50 px-2.5 py-1 text-xs",
|
||||
voiceStatus.connected ? "text-[#23a1eb]" : "text-[#666666]",
|
||||
)}
|
||||
>
|
||||
<VoiceIndicator voiceStatus={voiceStatus} />
|
||||
</Badge>
|
||||
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN Sidebar — Navigation command center
|
||||
* Minimal, collapsed by default, dengan Mascot yang playful.
|
||||
* Fokus: Guild Moderation Watcher untuk komunitas IMPHNEN.
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
MessageSquare,
|
||||
Radio,
|
||||
} 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";
|
||||
@@ -7,12 +17,29 @@ 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 },
|
||||
];
|
||||
const navItems: Array<{
|
||||
id: DashboardTab;
|
||||
label: string;
|
||||
icon: typeof Radio;
|
||||
badge?: string;
|
||||
}> = [
|
||||
{
|
||||
id: "messages",
|
||||
label: "Pesan & Moderasi",
|
||||
icon: MessageSquare,
|
||||
badge: "Live",
|
||||
},
|
||||
{
|
||||
id: "live",
|
||||
label: "Voice & Media",
|
||||
icon: Radio,
|
||||
},
|
||||
{
|
||||
id: "dashboard",
|
||||
label: "Dashboard Guild",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
activeTab: DashboardTab;
|
||||
@@ -21,60 +48,87 @@ interface SidebarProps {
|
||||
recentMessages?: MessageRecord[];
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
flaggedCount?: number;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
collapsed = true,
|
||||
collapsed = false,
|
||||
recentMessages = [],
|
||||
guildId,
|
||||
channelId,
|
||||
flaggedCount = 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"],
|
||||
lastActivity: recentMessages.length > 0 ? "Aktif" : "Idle",
|
||||
topicsDiscussed: ["Pesan", "Moderasi"],
|
||||
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",
|
||||
"relative hidden shrink-0 flex-col overflow-visible",
|
||||
"border-r border-[#e0e0e0]/50",
|
||||
"bg-white/70 backdrop-blur-sm",
|
||||
"transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]",
|
||||
"md:flex",
|
||||
collapsed ? "w-16" : "w-64",
|
||||
)}
|
||||
layout
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
>
|
||||
{/* App icon only — no branding text */}
|
||||
{/* ── Brand Icon ────────────────────────────────────────────── */}
|
||||
<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"
|
||||
/>
|
||||
{/* Logo */}
|
||||
<div className="relative">
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg"
|
||||
alt="IMPHNEN"
|
||||
className="h-8 w-8 rounded-xl"
|
||||
/>
|
||||
{/* Live indicator dot */}
|
||||
<span className="absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-[#22c55e] ring-2 ring-white animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Mascot image — only when expanded */}
|
||||
{/* Brand text when expanded */}
|
||||
{!collapsed && (
|
||||
<div className="mt-4 text-center">
|
||||
<h2 className="font-sans text-sm font-bold text-[#1a1a1a]">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-[#23a1eb] to-[#5865f2]">
|
||||
IMPHNEN
|
||||
</span>
|
||||
</h2>
|
||||
<p className="font-sans text-[10px] font-medium text-[#666666] mt-0.5 tracking-wider uppercase">
|
||||
Guild Watcher
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mascot — 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"
|
||||
alt="Mascot IMPHNEN"
|
||||
className="mt-4 h-auto w-[120px] object-contain drop-shadow-md hover:animate-mascot-wiggle cursor-pointer"
|
||||
onClick={() => mascotChat.setIsOpen(!mascotChat.isOpen)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation items — centered vertically */}
|
||||
{/* ── Navigation Items ──────────────────────────────────────── */}
|
||||
<div className="flex flex-1 flex-col justify-center">
|
||||
<div className="flex flex-col gap-1 px-2">
|
||||
{navItems.map((item) => {
|
||||
@@ -86,38 +140,67 @@ export function Sidebar({
|
||||
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",
|
||||
"group relative flex items-center rounded-xl p-2.5",
|
||||
"font-sans text-sm font-medium",
|
||||
"transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]",
|
||||
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",
|
||||
? "bg-[#e1f0fd] text-[#23a1eb] ring-1 ring-[#23a1eb]/20"
|
||||
: "text-[#666666] hover:bg-[#e1f0fd]/50 hover:text-[#23a1eb]/70",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
<div className="relative">
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{/* Flagged dot indicator */}
|
||||
{item.id === "messages" && flaggedCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-[#e4405f] ring-1 ring-white" />
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="flex items-center justify-between flex-1 min-w-0">
|
||||
<span>{item.label}</span>
|
||||
{item.badge && (
|
||||
<span className="font-sans text-[10px] font-semibold text-[#23a1eb] bg-[#e1f0fd] px-1.5 py-0.5 rounded-full">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mascot button */}
|
||||
{/* ── Bottom: Mascot Chat 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"
|
||||
className={cn(
|
||||
"relative z-50 rounded-xl p-1.5",
|
||||
"transition-all duration-200 hover:scale-105",
|
||||
"focus:outline-none focus:ring-2 focus:ring-[#23a1eb]/40",
|
||||
mascotChat.isOpen && "bg-[#e1f0fd] ring-1 ring-[#23a1eb]/30",
|
||||
)}
|
||||
title="Chat dengan Mascot"
|
||||
>
|
||||
<MascotImage size="sm" />
|
||||
<div className="relative">
|
||||
<MascotImage size="sm" />
|
||||
{mascotChat.isOpen && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-[#23a1eb] ring-1 ring-white" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</motion.nav>
|
||||
|
||||
{/* ── Mascot Chatbot Panel ────────────────────────────────────── */}
|
||||
<MascotChatbot
|
||||
isOpen={mascotChat.isOpen}
|
||||
onClose={() => mascotChat.setIsOpen(false)}
|
||||
onSendMessage={mascotChat.handleSendMessage}
|
||||
mascotName="IMPHNEN Mascot"
|
||||
mascotName="Mascot IMPHNEN"
|
||||
className="fixed bottom-[170px] left-[80px] z-[9999]"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { LayoutDashboard, MessageSquare, Radio } from 'lucide-react';
|
||||
import type { DashboardTab } from '../entities/ui/types.js';
|
||||
import { cn } from '../shared/lib/utils';
|
||||
|
||||
const tabs: { id: DashboardTab; label: string; icon: typeof MessageSquare }[] = [
|
||||
{ id: 'messages', label: 'Pesan & Moderasi', icon: MessageSquare },
|
||||
{ id: 'live', label: 'Voice & Media', icon: Radio },
|
||||
{ id: 'dashboard', label: 'Dashboard Guild', icon: LayoutDashboard },
|
||||
];
|
||||
|
||||
interface TabStripProps {
|
||||
activeTab: DashboardTab;
|
||||
onTabChange: (tab: DashboardTab) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TabStrip({ activeTab, onTabChange, className }: TabStripProps) {
|
||||
return (
|
||||
<nav className={cn('sticky top-14 z-30 border-b border-[#e0e0e0] bg-white/80 backdrop-blur-sm overflow-x-auto scrollbar-none', className)}>
|
||||
<div className="mx-auto flex max-w-[1280px] gap-1 px-4 md:px-6 lg:px-8">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={cn(
|
||||
'relative flex items-center gap-2 px-4 py-3 whitespace-nowrap text-sm font-semibold transition-colors duration-150',
|
||||
isActive ? 'text-[#23a1eb]' : 'text-[#666666] hover:text-[#1a1a1a]',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="tab-indicator"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#23a1eb] rounded-full"
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN MascotChatbot — AI companion widget
|
||||
* Floating chat panel dengan IMPHNEN signature branding.
|
||||
* Friendly Geometry: rounded-xl container, rounded-lg elements.
|
||||
* Signature timing: 150ms cubic-bezier(0.4, 0, 0.2, 1) untuk interaksi.
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Maximize2, MessageCircle, Minimize2, Send, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -27,7 +34,7 @@ export function MascotChatbot({
|
||||
onClose,
|
||||
isOpen = false,
|
||||
onSendMessage,
|
||||
mascotName = "Mascot",
|
||||
mascotName = "Mascot IMPHNEN",
|
||||
mascotAvatar = "https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png",
|
||||
className,
|
||||
}: MascotChatbotProps) {
|
||||
@@ -36,7 +43,7 @@ export function MascotChatbot({
|
||||
id: "init-1",
|
||||
role: "mascot",
|
||||
content:
|
||||
"Halo! 👋 Saya mascot mu. Ada yang bisa aku bantu tentang conversation atau analytics?",
|
||||
"Halo! 👋 Aku mascot IMPHNEN. Ada yang bisa aku bantu tentang conversation atau analytics?",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
@@ -74,7 +81,6 @@ export function MascotChatbot({
|
||||
if (onSendMessage) {
|
||||
response = await onSendMessage(input.trim());
|
||||
} else {
|
||||
// Default mascot responses
|
||||
response = generateMascotResponse(input.trim(), messages);
|
||||
}
|
||||
|
||||
@@ -110,62 +116,64 @@ export function MascotChatbot({
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className={cn(
|
||||
"w-96 bg-card rounded-xl shadow-2xl border border-border overflow-hidden flex flex-col",
|
||||
isMinimized ? "h-16" : "h-[520px]",
|
||||
"w-96 bg-white rounded-xl shadow-lg border border-[#e0e0e0] overflow-hidden flex flex-col",
|
||||
isMinimized ? "h-14" : "h-[520px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-primary to-primary/80 text-white p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center">
|
||||
<MessageCircle className="h-5 w-5" />
|
||||
{/* ── Header: Branded Gradient ───────────────────────────────── */}
|
||||
<div className="bg-gradient-to-r from-[#23a1eb] to-[#1877f2] p-3.5 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg bg-white/20 flex items-center justify-center">
|
||||
<MessageCircle className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">{mascotName}</h3>
|
||||
<p className="text-xs text-white/80">
|
||||
<h3 className="font-sans text-sm font-semibold text-white leading-tight">
|
||||
{mascotName}
|
||||
</h3>
|
||||
<p className="font-sans text-[11px] text-white/75 leading-tight mt-0.5">
|
||||
{loading ? "Mengetik..." : "Online"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"
|
||||
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors duration-150 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
title={isMinimized ? "Maximize" : "Minimize"}
|
||||
>
|
||||
{isMinimized ? (
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
<Maximize2 className="h-3.5 w-3.5 text-white" />
|
||||
) : (
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
<Minimize2 className="h-3.5 w-3.5 text-white" />
|
||||
)}
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => {
|
||||
onClose?.();
|
||||
}}
|
||||
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"
|
||||
onClick={() => onClose?.()}
|
||||
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors duration-150 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
title="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-3.5 w-3.5 text-white" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{/* ── Messages Area ──────────────────────────────────────────── */}
|
||||
{!isMinimized && (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-card">
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-white">
|
||||
{messages.map((message) => (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.15, ease: [0.4, 0, 0.2, 1] }}
|
||||
className={cn(
|
||||
"flex gap-2",
|
||||
message.role === "user" ? "justify-end" : "justify-start",
|
||||
@@ -175,21 +183,23 @@ export function MascotChatbot({
|
||||
<img
|
||||
src={mascotAvatar}
|
||||
alt={mascotName}
|
||||
className="w-6 h-6 rounded-full object-cover"
|
||||
className="w-6 h-6 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-xs px-3 py-2 rounded-xl text-sm break-words",
|
||||
"max-w-xs px-3 py-2 rounded-xl text-sm break-words leading-relaxed",
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground rounded-br-none"
|
||||
: "bg-muted text-foreground rounded-bl-none",
|
||||
? "bg-[#23a1eb] text-white rounded-br-[4px]"
|
||||
: "bg-[#f5f5f5] text-[#1a1a1a] rounded-bl-[4px]",
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* ── Typing Indicator ──────────────────────────────── */}
|
||||
{loading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -199,14 +209,14 @@ export function MascotChatbot({
|
||||
<img
|
||||
src={mascotAvatar}
|
||||
alt={mascotName}
|
||||
className="w-6 h-6 rounded-full object-cover"
|
||||
className="w-6 h-6 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
<div className="bg-muted rounded-xl rounded-bl-none px-3 py-2">
|
||||
<div className="bg-[#f5f5f5] rounded-xl rounded-bl-[4px] px-3 py-2.5">
|
||||
<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"
|
||||
className="w-2 h-2 bg-[#666666] rounded-full"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ y: [0, -4, 0] }}
|
||||
@@ -215,7 +225,7 @@ export function MascotChatbot({
|
||||
repeat: Infinity,
|
||||
delay: 0.1,
|
||||
}}
|
||||
className="w-2 h-2 bg-muted-foreground rounded-full"
|
||||
className="w-2 h-2 bg-[#666666] rounded-full"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ y: [0, -4, 0] }}
|
||||
@@ -224,7 +234,7 @@ export function MascotChatbot({
|
||||
repeat: Infinity,
|
||||
delay: 0.2,
|
||||
}}
|
||||
className="w-2 h-2 bg-muted-foreground rounded-full"
|
||||
className="w-2 h-2 bg-[#666666] rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,10 +243,10 @@ export function MascotChatbot({
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
{/* ── Input Area ──────────────────────────────────────── */}
|
||||
<form
|
||||
onSubmit={handleSendMessage}
|
||||
className="border-t border-border p-3 bg-card"
|
||||
className="border-t border-[#e0e0e0] p-3 bg-white"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -245,14 +255,14 @@ export function MascotChatbot({
|
||||
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"
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-[#e0e0e0] bg-white text-[#1a1a1a] placeholder:text-[#999999] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#23a1eb]/30 focus-visible:border-[#23a1eb] text-sm disabled:opacity-50 transition-all duration-150 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
/>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
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"
|
||||
className="p-2 bg-[#23a1eb] text-white rounded-lg hover:bg-[#1a8fd9] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-150 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</motion.button>
|
||||
@@ -272,16 +282,16 @@ function generateMascotResponse(
|
||||
): 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!",
|
||||
halo: "Halo juga! 👋 Senang ketemu kamu di IMPHNEN. Ada yang bisa aku bantu?",
|
||||
terima: "Sama-sama! 😊 Senang bisa membantu!",
|
||||
apa: "Aku adalah mascot virtual IMPHNEN 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? 😎",
|
||||
"Aku mascot IMPHNEN yang baik hati! Siap membantu dengan insights tentang chat dan analytics.",
|
||||
chat: "Setiap chat yang terjadi di guild dianalisis 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! 📊",
|
||||
"Analytics menunjukkan pola conversation, waktu aktif, partisipan utama, dan banyak hal menarik! 📊",
|
||||
berapa:
|
||||
"Tanya aku 'berapa pesan hari ini' atau 'berapa orang yang chat' dan aku akan jawab dengan data real-time!",
|
||||
};
|
||||
@@ -292,10 +302,9 @@ function generateMascotResponse(
|
||||
}
|
||||
}
|
||||
|
||||
// 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? 🤔`;
|
||||
return `Menarik! "${input}" — itu hal yang perlu diperhatikan. Ada yang lain ingin kamu ketahui? 🤔`;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN MascotImage — Anime mascot PNG dengan floating chat bubble
|
||||
* Signature: rounded-xl untuk container, spring transitions, primary glow.
|
||||
* Mascot adalah "wajah" IMPHNEN — playful dan approachable.
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
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;
|
||||
@@ -23,9 +23,9 @@ const sizeMap = {
|
||||
};
|
||||
|
||||
const chatSizeMap = {
|
||||
sm: "max-w-xs",
|
||||
md: "max-w-sm",
|
||||
lg: "max-w-md",
|
||||
sm: "max-w-[200px]",
|
||||
md: "max-w-xs",
|
||||
lg: "max-w-sm",
|
||||
};
|
||||
|
||||
export function MascotImage({
|
||||
@@ -43,7 +43,7 @@ export function MascotImage({
|
||||
if (showChat && chatMessage) {
|
||||
setIsVisible(true);
|
||||
if (persistChat) return;
|
||||
const timer = setTimeout(() => setIsVisible(false), 8000); // Auto hide after 8s
|
||||
const timer = setTimeout(() => setIsVisible(false), 8000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsVisible(false);
|
||||
@@ -53,7 +53,7 @@ export function MascotImage({
|
||||
<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"
|
||||
alt="Mascot IMPHNEN"
|
||||
className={`object-contain drop-shadow-md ${sizeClass} ${className}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
@@ -70,16 +70,14 @@ export function MascotImage({
|
||||
>
|
||||
<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-white/80" />
|
||||
<div className="bg-[#23a1eb]/90 text-white rounded-xl px-3 py-2 shadow-md backdrop-blur-sm border border-[#23a1eb]/30">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<MessageCircle className="h-3.5 w-3.5 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 className="absolute -bottom-1 left-3 w-2.5 h-2.5 bg-[#23a1eb]/80 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -89,14 +87,14 @@ export function MascotImage({
|
||||
}
|
||||
|
||||
/**
|
||||
* EmptyStateMascot — Mascot for empty states
|
||||
* Replaces ChibiMascot when showing empty data states
|
||||
* EmptyStateMascot — Mascot untuk empty states
|
||||
* Menampilkan mascot yang redup dengan pesan "Belum ada data"
|
||||
*/
|
||||
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>
|
||||
<p className="font-sans text-sm text-[#666666]">Belum ada data ditampilkan</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN Particle Background — Glow orbs yang subtle
|
||||
* Menggunakan primary (#23a1eb) dan tertiary (#5865f2) glow.
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ParticleBackground() {
|
||||
const [reducedMotion, setReducedMotion] = useState(false);
|
||||
const [reducedMotion, setReducedMotion] = useState(true);
|
||||
const [isMobile, setIsMobile] = useState(true);
|
||||
const [shouldRender, setShouldRender] = 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);
|
||||
const mqReduced = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
setReducedMotion(mqReduced.matches);
|
||||
const mqMobile = window.matchMedia("(max-width: 768px)");
|
||||
setIsMobile(mqMobile.matches);
|
||||
const handleReduced = (e: MediaQueryListEvent) => setReducedMotion(e.matches);
|
||||
const handleMobile = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
mqReduced.addEventListener("change", handleReduced);
|
||||
mqMobile.addEventListener("change", handleMobile);
|
||||
requestAnimationFrame(() => setShouldRender(true));
|
||||
return () => {
|
||||
mqReduced.removeEventListener("change", handleReduced);
|
||||
mqMobile.removeEventListener("change", handleMobile);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (reducedMotion) return null;
|
||||
if (reducedMotion || isMobile || !shouldRender) return null;
|
||||
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
const primaryColor = root.getPropertyValue("--primary").trim() || "#23a1eb";
|
||||
const tertiaryColor = root.getPropertyValue("--tertiary").trim() || "#5865f2";
|
||||
const secondaryColor = root.getPropertyValue("--secondary").trim() || "#1877f2";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -19,12 +39,30 @@ export function ParticleBackground() {
|
||||
aria-hidden="true"
|
||||
style={{ zIndex: -1 }}
|
||||
>
|
||||
{/* Top-right glow orb */}
|
||||
<div className="absolute -top-40 -right-40 h-[500px] w-[500px] rounded-full bg-primary/10 blur-3xl animate-glow-pulse" />
|
||||
{/* Bottom-left glow orb */}
|
||||
{/* Top-right glow — IMPHNEN Primary #23a1eb */}
|
||||
<div
|
||||
className="absolute -bottom-40 -left-40 h-[400px] w-[400px] rounded-full bg-blue-400/10 blur-3xl animate-glow-pulse"
|
||||
style={{ animationDelay: "1.5s" }}
|
||||
className="absolute -top-40 -right-40 h-[500px] w-[500px] rounded-full blur-3xl animate-glow-pulse"
|
||||
style={{
|
||||
backgroundColor: `${primaryColor}14`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bottom-left glow — Discord Tertiary #5865f2 */}
|
||||
<div
|
||||
className="absolute -bottom-40 -left-40 h-[400px] w-[400px] rounded-full blur-3xl"
|
||||
style={{
|
||||
backgroundColor: `${tertiaryColor}0f`,
|
||||
animation: "glowPulse 3s ease-in-out infinite",
|
||||
animationDelay: "1.5s",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Center-subtle glow — Secondary #1877f2 */}
|
||||
<div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[600px] w-[600px] rounded-full blur-3xl"
|
||||
style={{
|
||||
backgroundColor: `${secondaryColor}08`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user