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,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ActiveSpeaker } from "./entities/voice/types.js";
|
||||
import { AuthOverlay } from "./features/auth";
|
||||
import { DashboardPanel } from "./features/dashboard";
|
||||
import { LivePanel } from "./features/live";
|
||||
import { useMediaControl } from "./features/live/hooks/useMediaControl";
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { getAppConfig } from "./shared/api/client";
|
||||
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
|
||||
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
|
||||
import { useTheme } from "./shared/hooks/useTheme";
|
||||
import { useUIState } from "./shared/hooks/useUIState";
|
||||
import { MobileTabBar } from "./shared/ui/MobileTabBar";
|
||||
import { useDashboardSocket } from "./shared/ws/socket";
|
||||
@@ -20,6 +22,22 @@ import { DashboardLayout } from "./widgets/DashboardLayout";
|
||||
|
||||
export default function App() {
|
||||
const { uiState, patchUIState } = useUIState();
|
||||
const [authenticated, setAuthenticated] = useState(() => {
|
||||
return sessionStorage.getItem("admin-password") !== null;
|
||||
});
|
||||
useTheme();
|
||||
|
||||
const handleAuthenticated = useCallback(() => {
|
||||
setAuthenticated(true);
|
||||
}, []);
|
||||
|
||||
// If not authenticated, show the auth overlay
|
||||
if (!authenticated) {
|
||||
const isPublicDashboard = import.meta.env.VITE_DASHBOARD_IS_PUBLIC === "true";
|
||||
if (!isPublicDashboard) {
|
||||
return <AuthOverlay onAuthenticated={handleAuthenticated} />;
|
||||
}
|
||||
}
|
||||
const voice = useVoiceControl();
|
||||
const media = useMediaControl();
|
||||
const messages = useMessages();
|
||||
|
||||
@@ -27,7 +27,7 @@ export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
|
||||
setError(null);
|
||||
try {
|
||||
await login(password);
|
||||
localStorage.setItem("admin-password", password);
|
||||
sessionStorage.setItem("admin-password", password);
|
||||
onAuthenticated();
|
||||
} catch {
|
||||
setError("Invalid password");
|
||||
|
||||
@@ -66,22 +66,22 @@ export function DashboardStatsContent() {
|
||||
title: "Today's Messages",
|
||||
value: stats.today_messages.toLocaleString(),
|
||||
icon: MessageSquare,
|
||||
color: "text-emerald-500",
|
||||
bg: "bg-emerald-100",
|
||||
color: "text-success",
|
||||
bg: "bg-success-soft",
|
||||
},
|
||||
{
|
||||
title: "Total Users",
|
||||
value: stats.total_users.toLocaleString(),
|
||||
icon: Users,
|
||||
color: "text-blue-500",
|
||||
bg: "bg-blue-100",
|
||||
color: "text-primary",
|
||||
bg: "bg-primary-soft",
|
||||
},
|
||||
{
|
||||
title: "Active Users (24h)",
|
||||
value: stats.active_users_24h.toLocaleString(),
|
||||
icon: UserCheck,
|
||||
color: "text-violet-500",
|
||||
bg: "bg-violet-100",
|
||||
color: "text-tertiary",
|
||||
bg: "bg-tertiary-soft",
|
||||
},
|
||||
{
|
||||
title: "Flagged",
|
||||
@@ -94,23 +94,23 @@ export function DashboardStatsContent() {
|
||||
title: "Clean",
|
||||
value: stats.total_clean.toLocaleString(),
|
||||
icon: ShieldAlert,
|
||||
color: "text-emerald-600",
|
||||
bg: "bg-emerald-100",
|
||||
color: "text-success",
|
||||
bg: "bg-success-soft",
|
||||
},
|
||||
{
|
||||
title: "Voice Recordings",
|
||||
value: stats.total_voice_recordings.toLocaleString(),
|
||||
icon: Mic,
|
||||
color: "text-cyan-500",
|
||||
bg: "bg-cyan-100",
|
||||
color: "text-info",
|
||||
bg: "bg-info-soft",
|
||||
onClick: () => patchUIState({ activeTab: "live" }),
|
||||
},
|
||||
{
|
||||
title: "AI Profiles",
|
||||
value: stats.total_profiles.toLocaleString(),
|
||||
icon: Users,
|
||||
color: "text-amber-500",
|
||||
bg: "bg-amber-100",
|
||||
color: "text-warning",
|
||||
bg: "bg-warning-soft",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -207,7 +207,7 @@ export function DashboardStatsContent() {
|
||||
<p className="text-xs text-muted-foreground mt-1">Pending</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4 text-center">
|
||||
<p className="text-2xl font-bold text-amber-500">
|
||||
<p className="text-2xl font-bold text-warning">
|
||||
{stats.moderation_overview.processing}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Processing</p>
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN DashboardPanel — Statistics Hub for Guild Moderation Watcher
|
||||
* Menampilkan overview komunitas dengan IMPHNEN approachable modernism.
|
||||
* Tiga tab: Stats (ringkasan), Users (profil pengguna), Channels (kanal).
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useState } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui";
|
||||
import {
|
||||
BarChart3,
|
||||
Hash,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "../../shared/ui";
|
||||
import { ChannelProfileDetail } from "./components/ChannelProfileDetail";
|
||||
import { ChannelSummaryList } from "./components/ChannelSummaryList";
|
||||
import { DashboardStatsContent } from "./components/DashboardStats";
|
||||
@@ -82,44 +98,67 @@ export function DashboardPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="stats">Stats</TabsTrigger>
|
||||
<TabsTrigger value="users">Users</TabsTrigger>
|
||||
<TabsTrigger value="channels">Channels</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="w-full">
|
||||
{/* ── Page Header ───────────────────────────────────────────────── */}
|
||||
<div className="mb-6">
|
||||
<h2 className="typo-headline-md text-[#1a1a1a]">
|
||||
Dashboard Guild
|
||||
</h2>
|
||||
<p className="typo-body-md text-[#666666] mt-1">
|
||||
Pantau statistik, profil pengguna, dan aktivitas kanal komunitas
|
||||
IMPHNEN secara real-time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TabsContent value="stats">
|
||||
<DashboardStatsContent />
|
||||
</TabsContent>
|
||||
{/* ── Tabs ────────────────────────────────────────────────────────── */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="mb-6 bg-[#f5f5f5] p-1 rounded-lg inline-flex">
|
||||
<TabsTrigger value="stats" className="flex items-center gap-1.5">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
<span>Statistik</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="flex items-center gap-1.5">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>Pengguna</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="channels" className="flex items-center gap-1.5">
|
||||
<Hash className="h-4 w-4" />
|
||||
<span>Kanal</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users">
|
||||
<UserSummaryList
|
||||
users={users}
|
||||
loading={usersLoading}
|
||||
error={usersError}
|
||||
search={userSearch}
|
||||
onSearchChange={setUserSearch}
|
||||
onLoadMore={loadMoreUsers}
|
||||
hasMore={hasMoreUsers}
|
||||
onRefetch={refetchUsers}
|
||||
onSelectUser={setSelectedUserId}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="stats">
|
||||
<DashboardStatsContent />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="channels">
|
||||
<ChannelSummaryList
|
||||
channels={channels}
|
||||
loading={channelsLoading}
|
||||
error={channelsError}
|
||||
search={channelSearch}
|
||||
onSearchChange={setChannelSearch}
|
||||
onLoadMore={loadMoreChannels}
|
||||
hasMore={hasMoreChannels}
|
||||
onRefetch={refetchChannels}
|
||||
onSelectChannel={setSelectedChannelId}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="users">
|
||||
<UserSummaryList
|
||||
users={users}
|
||||
loading={usersLoading}
|
||||
error={usersError}
|
||||
search={userSearch}
|
||||
onSearchChange={setUserSearch}
|
||||
onLoadMore={loadMoreUsers}
|
||||
hasMore={hasMoreUsers}
|
||||
onRefetch={refetchUsers}
|
||||
onSelectUser={setSelectedUserId}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="channels">
|
||||
<ChannelSummaryList
|
||||
channels={channels}
|
||||
loading={channelsLoading}
|
||||
error={channelsError}
|
||||
search={channelSearch}
|
||||
onSearchChange={setChannelSearch}
|
||||
onLoadMore={loadMoreChannels}
|
||||
hasMore={hasMoreChannels}
|
||||
onRefetch={refetchChannels}
|
||||
onSelectChannel={setSelectedChannelId}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
s.speaking ? "text-emerald-600" : "text-muted-foreground"
|
||||
s.speaking ? "text-success" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{s.speaking ? "Speaking" : "Silent"}
|
||||
|
||||
@@ -40,9 +40,12 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||
const barWidth = width / levels.length;
|
||||
const maxBarHeight = height * 0.85;
|
||||
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
const primaryColor = root.getPropertyValue("--primary").trim() || "#23a1eb";
|
||||
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||
gradient.addColorStop(0, "#23a1eb");
|
||||
gradient.addColorStop(1, "#3eb0f2");
|
||||
gradient.addColorStop(0, primaryColor);
|
||||
gradient.addColorStop(1, primaryColor);
|
||||
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
const level = levels[i];
|
||||
|
||||
@@ -121,7 +121,7 @@ export function RecordingsSubPanel() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{recordings.map((rec) => (
|
||||
<div key={rec.id} className="rounded-xl border border-sky-200 bg-white">
|
||||
<div key={rec.id} className="rounded-xl border border-[#e0e0e0] bg-white">
|
||||
<div className="flex items-center gap-4 p-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Mic className="h-5 w-5" />
|
||||
|
||||
@@ -16,7 +16,7 @@ function kindBadge(kind: ImageItem["kind"]): string {
|
||||
case "attachment":
|
||||
return "bg-primary-soft text-primary border-primary/30";
|
||||
case "embed":
|
||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
return "bg-tertiary-soft text-tertiary border-tertiary/20";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,13 +80,13 @@ function parseStringList(value?: string | null): string[] {
|
||||
function severityColor(severity: string) {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "bg-red-100 text-red-700 border-red-200";
|
||||
return "bg-destructive-soft text-destructive border-destructive/20";
|
||||
case "high":
|
||||
return "bg-orange-100 text-orange-700 border-orange-200";
|
||||
return "bg-warning-soft text-warning border-warning/20";
|
||||
case "medium":
|
||||
return "bg-yellow-100 text-yellow-700 border-yellow-200";
|
||||
return "bg-warning-soft text-warning border-warning/20";
|
||||
case "low":
|
||||
return "bg-blue-100 text-blue-700 border-blue-200";
|
||||
return "bg-info-soft text-info border-info/20";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground border-border";
|
||||
}
|
||||
@@ -238,18 +238,18 @@ function MessageRow({
|
||||
|
||||
if (message.is_forward) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-amber-400/40 pl-2.5 py-1">
|
||||
<Forward className="h-3 w-3 shrink-0 text-amber-500" />
|
||||
<span className="font-medium text-amber-600/70">Forwarded</span>
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-warning/40 pl-2.5 py-1">
|
||||
<Forward className="h-3 w-3 shrink-0 text-warning" />
|
||||
<span className="font-medium text-warning/70">Forwarded</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.is_crosspost) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-sky-400/40 pl-2.5 py-1">
|
||||
<MessageCircle className="h-3 w-3 shrink-0 text-sky-500" />
|
||||
<span className="font-medium text-sky-600/70">Crossposted</span>
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-info/40 pl-2.5 py-1">
|
||||
<MessageCircle className="h-3 w-3 shrink-0 text-info" />
|
||||
<span className="font-medium text-info/70">Crossposted</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -409,8 +409,8 @@ function MessageRow({
|
||||
<div
|
||||
className={`rounded-lg border-l-[3px] px-3 py-2 ${
|
||||
aiStatus === "flagged"
|
||||
? "border-l-pink-400 bg-pink-50/40"
|
||||
: "border-l-emerald-400 bg-emerald-50/40"
|
||||
? "border-l-tertiary bg-tertiary/5"
|
||||
: "border-l-success bg-success-soft"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2 text-[11px]">
|
||||
@@ -431,7 +431,7 @@ function MessageRow({
|
||||
|
||||
{/* AI Error */}
|
||||
{message.ai_error ? (
|
||||
<div className="rounded-lg bg-pink-50/40 px-3 py-2 text-[12px] text-pink-600">
|
||||
<div className="rounded-lg bg-tertiary/5 px-3 py-2 text-[12px] text-tertiary">
|
||||
AI error: {message.ai_error}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -451,7 +451,7 @@ function MessageRow({
|
||||
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
|
||||
</Button>
|
||||
{aiStatus === "error" && (
|
||||
<span className="text-[11px] text-pink-600/70">
|
||||
<span className="text-[11px] text-tertiary/70">
|
||||
Click to retry analysis
|
||||
</span>
|
||||
)}
|
||||
@@ -483,7 +483,7 @@ export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
|
||||
return (
|
||||
<article
|
||||
className={`group rounded-xl border bg-card shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
|
||||
firstMsg.deleted_at ? "border-red-200 opacity-60" : "border-border"
|
||||
firstMsg.deleted_at ? "border-destructive/20 opacity-60" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3 p-4">
|
||||
|
||||
@@ -138,7 +138,7 @@ export function MessagesPanel({
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-emerald-100 text-emerald-700 border-emerald-200"
|
||||
className="text-xs bg-success-soft text-success border-success/20"
|
||||
>
|
||||
{stats.clean} clean
|
||||
</Badge>
|
||||
@@ -150,7 +150,7 @@ export function MessagesPanel({
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-orange-100 text-orange-700 border-orange-200"
|
||||
className="text-xs bg-warning-soft text-warning border-warning/20"
|
||||
>
|
||||
{stats.error} error
|
||||
</Badge>
|
||||
@@ -163,7 +163,7 @@ export function MessagesPanel({
|
||||
{stats.deleted > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-red-100 text-red-700 border-red-200"
|
||||
className="text-xs bg-destructive-soft text-destructive border-destructive/20"
|
||||
>
|
||||
{stats.deleted} deleted
|
||||
</Badge>
|
||||
@@ -236,7 +236,7 @@ export function MessagesPanel({
|
||||
</Button>
|
||||
)}
|
||||
{retriedCount !== null && (
|
||||
<span className="text-xs text-emerald-600">
|
||||
<span className="text-xs text-success">
|
||||
{retriedCount} message{retriedCount !== 1 ? "s" : ""} queued for
|
||||
re-analysis
|
||||
</span>
|
||||
|
||||
@@ -49,12 +49,16 @@ class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache admin password in memory — read from localStorage once on first call
|
||||
// Cache admin password in memory — read from sessionStorage once on first call
|
||||
let _cachedPassword: string | null = null;
|
||||
|
||||
function getAdminPassword(): string | null {
|
||||
if (_cachedPassword === null) {
|
||||
_cachedPassword = localStorage.getItem("admin-password");
|
||||
try {
|
||||
_cachedPassword = sessionStorage.getItem("admin-password");
|
||||
} catch {
|
||||
_cachedPassword = null;
|
||||
}
|
||||
}
|
||||
return _cachedPassword;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
const STORAGE_KEY = 'imphnen-theme';
|
||||
|
||||
function getSystemTheme(): 'light' | 'dark' {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function applyTheme(resolved: 'light' | 'dark') {
|
||||
const root = document.documentElement;
|
||||
const transitioning = root.classList.contains('theme-transitioning');
|
||||
if (!transitioning) root.classList.add('theme-transitioning');
|
||||
root.dataset.theme = resolved;
|
||||
if (!transitioning) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove('theme-transitioning')));
|
||||
}
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored;
|
||||
return 'system';
|
||||
});
|
||||
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
||||
const setTheme = useCallback((t: Theme) => { setThemeState(t); localStorage.setItem(STORAGE_KEY, t); }, []);
|
||||
const toggle = useCallback(() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'), [resolvedTheme, setTheme]);
|
||||
useEffect(() => { applyTheme(resolvedTheme); }, [resolvedTheme]);
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return;
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = () => applyTheme(getSystemTheme());
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, [theme]);
|
||||
return { theme, setTheme, resolvedTheme, toggle };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
|
||||
import type { DashboardTab } from "../../entities/ui/types.js";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -18,7 +19,7 @@ export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
|
||||
<nav
|
||||
aria-label="Main navigation"
|
||||
role="tablist"
|
||||
className="fixed bottom-0 left-0 right-0 z-50 flex border-t border-border bg-card shadow-lg md:hidden"
|
||||
className="fixed bottom-0 left-0 right-0 z-50 flex border-t border-border bg-white/80 backdrop-blur-lg pb-4 shadow-lg md:hidden"
|
||||
>
|
||||
{tabs.map(({ id, label, Icon }) => (
|
||||
<button
|
||||
@@ -29,18 +30,19 @@ export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
|
||||
type="button"
|
||||
onClick={() => onTabChange(id)}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors",
|
||||
activeTab === id ? "text-primary" : "text-muted-foreground",
|
||||
"relative flex flex-1 flex-col items-center gap-0.5 py-2 pt-3 text-xs font-medium transition-colors",
|
||||
activeTab === id ? "text-[#23a1eb]" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="text-[10px]">{label}</span>
|
||||
{activeTab === id && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-0.5 w-6 rounded-full bg-primary mx-auto mt-0.5"
|
||||
<motion.div
|
||||
layoutId="mobile-tab-dot"
|
||||
className="absolute top-0 h-1 w-6 rounded-full bg-[#23a1eb]"
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="text-[10px]">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN Badge — Pill untuk status, kategori, dan label micro-interaction
|
||||
* rounded-full (9999px), padding 4px 12px, font label-sm (12px, 500 weight)
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import type * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type BadgeVariant =
|
||||
| "default"
|
||||
| "secondary"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "info";
|
||||
| "default" /* Primary soft — #e1f0fd bg, #0d4a7a text */
|
||||
| "primary" /* Same as default, explicit alias */
|
||||
| "secondary" /* #e7f1ff bg, #003d99 text */
|
||||
| "tertiary" /* #eef0ff bg, #1a2466 text */
|
||||
| "destructive" /* #ffebee bg, #e4405f text */
|
||||
| "outline" /* Border only, no fill */
|
||||
| "success" /* #dcfce7 bg, green text */
|
||||
| "warning" /* #fef3c7 bg, amber text */
|
||||
| "info"; /* #dbeafe bg, blue text */
|
||||
|
||||
const variants: Record<BadgeVariant, string> = {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-muted text-muted-foreground",
|
||||
destructive: "border-transparent bg-destructive/15 text-destructive",
|
||||
outline: "border-border text-foreground",
|
||||
success:
|
||||
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300",
|
||||
warning:
|
||||
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300",
|
||||
info: "border-transparent bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300",
|
||||
default: "bg-[#e1f0fd] text-[#0d4a7a] border-transparent",
|
||||
primary: "bg-[#e1f0fd] text-[#0d4a7a] border-transparent",
|
||||
secondary: "bg-[#e7f1ff] text-[#003d99] border-transparent",
|
||||
tertiary: "bg-[#eef0ff] text-[#1a2466] border-transparent",
|
||||
destructive: "bg-[#ffebee] text-[#e4405f] border-transparent",
|
||||
outline: "bg-transparent text-[#666666] border-[#e0e0e0]",
|
||||
success: "bg-success-soft text-success border-transparent",
|
||||
warning: "bg-warning-soft text-warning border-transparent",
|
||||
info: "bg-[#dbeafe] text-[#1e40af] border-transparent",
|
||||
};
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
@@ -35,7 +42,9 @@ export function Badge({
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors",
|
||||
"inline-flex items-center rounded-full border px-3 py-1",
|
||||
"font-sans text-xs font-medium leading-4 tracking-[0.03em]",
|
||||
"transition-colors duration-[150ms] ease-[cubic-bezier(0.4,0,0.2,1)]",
|
||||
variants[variant],
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -1,30 +1,65 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN Button — Friendly, percaya diri, responsif.
|
||||
* Primary: #23a1eb → #1a8fd9 → #0877c1
|
||||
* Secondary: transparan dengan 1px border, fill subtle di hover
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import type * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type ButtonVariant =
|
||||
| "default"
|
||||
| "secondary"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "ghost";
|
||||
type ButtonSize = "default" | "sm" | "lg" | "icon";
|
||||
| "default" /* Primary IMPHNEN blue */
|
||||
| "secondary" /* Outline with subtle fill */
|
||||
| "tertiary" /* Discord-style blurple */
|
||||
| "destructive"/* Red semantic */
|
||||
| "outline" /* Light border, no fill */
|
||||
| "ghost" /* No border, fill on hover */
|
||||
| "link"; /* Text-only */
|
||||
type ButtonSize = "default" | "sm" | "lg" | "icon" | "icon-sm";
|
||||
|
||||
const variants: Record<ButtonVariant, string> = {
|
||||
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
default:
|
||||
"bg-[#23a1eb] text-white shadow-sm " +
|
||||
"hover:bg-[#1a8fd9] " +
|
||||
"active:bg-[#0877c1] " +
|
||||
"focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
|
||||
secondary:
|
||||
"bg-transparent text-[#23a1eb] border border-[#e0e0e0] " +
|
||||
"hover:bg-[#f0f0f0] hover:border-[#23a1eb] " +
|
||||
"active:bg-[#e1f0fd] " +
|
||||
"focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
"bg-[#e4405f] text-white shadow-sm " +
|
||||
"hover:bg-[#d63856] " +
|
||||
"active:bg-[#c2304d] " +
|
||||
"focus-visible:ring-2 focus-visible:ring-[#e4405f]/40",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
"bg-transparent text-[#1a1a1a] border border-[#e0e0e0] " +
|
||||
"hover:bg-[#f0f0f0] hover:text-[#23a1eb] " +
|
||||
"active:bg-[#e1f0fd] " +
|
||||
"focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
|
||||
ghost:
|
||||
"bg-transparent text-[#1a1a1a] " +
|
||||
"hover:bg-[#f0f0f0] hover:text-[#23a1eb] " +
|
||||
"active:bg-[#e1f0fd] " +
|
||||
"focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
|
||||
link:
|
||||
"bg-transparent text-[#23a1eb] underline-offset-4 " +
|
||||
"hover:underline " +
|
||||
"active:text-[#0877c1]",
|
||||
tertiary:
|
||||
"bg-[#5865f2] text-white shadow-sm " +
|
||||
"hover:bg-[#5865f2]/90 " +
|
||||
"focus-visible:ring-2 focus-visible:ring-[#5865f2]/40",
|
||||
};
|
||||
|
||||
const sizes: Record<ButtonSize, string> = {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-lg px-3",
|
||||
lg: "h-11 rounded-lg px-8",
|
||||
icon: "h-10 w-10",
|
||||
default: "h-11 px-6 py-3", /* 44px height, 24px horizontal */
|
||||
sm: "h-9 rounded-lg px-3 py-2", /* 36px compact */
|
||||
lg: "h-12 rounded-lg px-8 py-3", /* 48px spacious */
|
||||
icon: "h-11 w-11", /* Square 44x44 */
|
||||
'icon-sm': 'h-8 w-8', /* Square 32x32 */
|
||||
};
|
||||
|
||||
export interface ButtonProps
|
||||
@@ -47,7 +82,13 @@ export function Button({
|
||||
<Comp
|
||||
aria-disabled={disabled || undefined}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium motion-safe:transition-all motion-safe:duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap",
|
||||
"font-sans font-semibold text-sm leading-5 tracking-[0.02em]",
|
||||
"rounded-lg", /* 1rem / 16px — Friendly Geometry */
|
||||
"transition-all duration-[150ms] ease-[cubic-bezier(0.4,0,0.2,1)]",
|
||||
"focus-visible:outline-none focus-visible:ring-offset-2",
|
||||
"active:scale-[0.97]",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className,
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN Card — Primary content container
|
||||
* rounded-xl (1.5rem), border subtle, shadow-sm default → shadow-md hover
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import type * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type CardVariant = 'default' | 'elevated' | 'bordered';
|
||||
|
||||
const variantClasses: Record<CardVariant, string> = {
|
||||
default: 'shadow-sm hover:shadow-md',
|
||||
elevated: 'shadow-md hover:shadow-lg',
|
||||
bordered: 'shadow-none border-2',
|
||||
};
|
||||
|
||||
export function Card({
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { variant?: CardVariant }) {
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-card text-card-foreground shadow-sm hover:shadow-md transition-shadow",
|
||||
"rounded-xl border border-[#e0e0e0] bg-white text-[#1a1a1a]",
|
||||
"transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]",
|
||||
"hover:border-[#23a1eb]",
|
||||
variantClasses[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -23,7 +40,7 @@ export function CardHeader({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
className={cn("flex flex-col space-y-1.5 p-6 pb-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -35,7 +52,10 @@ export function CardTitle({
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
className={cn(
|
||||
"font-sans font-semibold text-lg leading-none tracking-tight text-[#1a1a1a]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -46,7 +66,10 @@ export function CardDescription({
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
<p
|
||||
className={cn("font-sans text-sm text-[#666666]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Inbox } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const fadeSlideUp = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] as const },
|
||||
},
|
||||
};
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon = Inbox,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
compact = false,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<motion.div
|
||||
variants={fadeSlideUp}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center text-center',
|
||||
compact ? 'py-8 gap-3' : 'py-16 gap-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn('rounded-full bg-primary-soft p-3', compact ? 'p-2' : 'p-4')}>
|
||||
<Icon className={cn('text-primary', compact ? 'h-5 w-5' : 'h-8 w-8')} />
|
||||
</div>
|
||||
{title && <h3 className="text-lg font-semibold text-[#1a1a1a]">{title}</h3>}
|
||||
{description && (
|
||||
<p className="text-sm text-[#666666] max-w-sm">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
export { Badge } from "./badge";
|
||||
export { Button } from "./button";
|
||||
export { EmptyState } from "./empty-state";
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN Input — Clean, approachable, dengan focus glow signature
|
||||
* rounded DEFAULT (0.5rem), bg #f0f0f0, border #e0e0e0
|
||||
* Focus: border #23a1eb + 3px glow
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import type * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
@@ -6,15 +12,38 @@ export interface InputProps
|
||||
errorId?: string;
|
||||
}
|
||||
|
||||
export function Input({ className, type, errorId, ...props }: InputProps) {
|
||||
type InputVariant = 'default' | 'soft';
|
||||
|
||||
const variantClasses: Record<InputVariant, string> = {
|
||||
default: 'border border-[#e0e0e0] bg-white',
|
||||
soft: 'border-transparent bg-[#f5f5f5] focus-visible:border-[#23a1eb]',
|
||||
};
|
||||
|
||||
export function Input({ className, type, errorId, variant = 'default', ...props }: InputProps & { variant?: InputVariant }) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
aria-describedby={errorId}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
/* Layout & sizing */
|
||||
"flex h-10 w-full rounded-lg px-3 py-2",
|
||||
/* Typography — Poppins body-md */
|
||||
"font-sans text-sm text-[#1a1a1a]",
|
||||
/* Visual — IMPHNEN input surface */
|
||||
variantClasses[variant],
|
||||
/* Placeholder */
|
||||
"placeholder:text-[#999999]",
|
||||
/* File input overrides */
|
||||
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
/* Focus — signature IMPHNEN glow */
|
||||
"focus-visible:outline-none",
|
||||
"focus-visible:border-[#23a1eb]",
|
||||
"focus-visible:shadow-[0_0_0_3px_rgba(35,161,235,0.1)]",
|
||||
/* Disabled */
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
/* Error state */
|
||||
props["aria-invalid"] === "true" &&
|
||||
"border-destructive ring-destructive/30",
|
||||
"border-[#e4405f] shadow-[0_0_0_3px_rgba(228,64,95,0.1)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN ScrollArea — Radix-based, scrollbar dengan primary accent
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import type * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -32,7 +36,7 @@ function ScrollBar({
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
"flex touch-none select-none transition-colors duration-[150ms]",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
@@ -41,7 +45,7 @@ function ScrollBar({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-primary/20" />
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-[#23a1eb]/20 hover:bg-[#23a1eb]/40 transition-colors" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN Select — Native select dengan styling IMPHNEN
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import type * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
@@ -21,9 +25,15 @@ export function Select({
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-10 w-full rounded-lg px-3 py-2",
|
||||
"font-sans text-sm text-[#1a1a1a]",
|
||||
"bg-[#f5f5f5] border border-[#e0e0e0]",
|
||||
"focus-visible:outline-none",
|
||||
"focus-visible:border-[#23a1eb]",
|
||||
"focus-visible:shadow-[0_0_0_3px_rgba(35,161,235,0.1)]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
props["aria-invalid"] === "true" &&
|
||||
"border-destructive ring-destructive/30",
|
||||
"border-[#e4405f] shadow-[0_0_0_3px_rgba(228,64,95,0.1)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN Skeleton — Loading state yang subtle & smooth
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type SkeletonVariant = "rounded" | "circular" | "rectangular";
|
||||
|
||||
const variantClasses: Record<SkeletonVariant, string> = {
|
||||
rounded: "rounded-lg",
|
||||
circular: "rounded-full",
|
||||
rectangular: "rounded-none",
|
||||
};
|
||||
|
||||
export function Skeleton({
|
||||
variant = "rounded",
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
}: HTMLAttributes<HTMLDivElement> & { variant?: SkeletonVariant }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
role="presentation"
|
||||
className={cn("rounded-lg bg-muted animate-shimmer", className)}
|
||||
className={cn(
|
||||
"bg-[#f0f0f0]",
|
||||
"animate-shimmer",
|
||||
variantClasses[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN StatusBadge — Untuk AI status moderation (flagged/clean/error/dll)
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
@@ -12,19 +16,14 @@ export type StatusType =
|
||||
| "none";
|
||||
|
||||
const statusStyles: Record<StatusType, string> = {
|
||||
flagged:
|
||||
"bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-800",
|
||||
clean:
|
||||
"bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950 dark:text-emerald-300 dark:border-emerald-800",
|
||||
warn: "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-800",
|
||||
pending: "bg-muted text-muted-foreground border-border",
|
||||
processing:
|
||||
"bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-800",
|
||||
error:
|
||||
"bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-800",
|
||||
deleted:
|
||||
"bg-gray-100 text-gray-500 border-gray-200 dark:bg-gray-900 dark:text-gray-400 dark:border-gray-800 line-through",
|
||||
none: "bg-muted text-muted-foreground border-border",
|
||||
flagged: "bg-[#ffebee] text-[#e4405f] border-[#ffcdd2]",
|
||||
clean: "bg-[#dcfce7] text-[#166534] border-[#bbf7d0]",
|
||||
warn: "bg-[#fef3c7] text-[#92400e] border-[#fde68a]",
|
||||
pending: "bg-[#f5f5f5] text-[#666666] border-[#e0e0e0]",
|
||||
processing: "bg-[#e1f0fd] text-[#0d4a7a] border-[#bce1fb]",
|
||||
error: "bg-[#ffebee] text-[#e4405f] border-[#ffcdd2]",
|
||||
deleted: "bg-[#f0f0f0] text-[#999999] border-[#e0e0e0] line-through",
|
||||
none: "bg-[#f5f5f5] text-[#666666] border-[#e0e0e0]",
|
||||
};
|
||||
|
||||
interface StatusBadgeProps {
|
||||
@@ -39,7 +38,8 @@ export function StatusBadge({ status, className, children }: StatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5",
|
||||
"font-sans text-xs font-medium leading-4 tracking-[0.03em]",
|
||||
style,
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN Tabs — Radix-based, style sesuai Approachable Modernism
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import type * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -11,7 +15,9 @@ export function TabsList({
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
"inline-flex h-10 items-center justify-center",
|
||||
"rounded-lg bg-[#f5f5f5] p-1",
|
||||
"text-[#666666]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -26,7 +32,13 @@ export function TabsTrigger({
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-lg px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
"inline-flex items-center justify-center whitespace-nowrap",
|
||||
"rounded-lg px-3 py-1.5",
|
||||
"font-sans text-sm font-medium",
|
||||
"transition-all duration-[150ms] ease-[cubic-bezier(0.4,0,0.2,1)]",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"data-[state=active]:bg-white data-[state=active]:text-[#1a1a1a] data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -41,7 +53,7 @@ export function TabsContent({
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
className={cn(
|
||||
"mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// ─── Toast notification system ──────────────────────────────────────────────
|
||||
// (no entity type imports needed — only uses string/ReactNode)
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN Toast — Notifikasi ringan dengan IMPHNEN brand accent
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
@@ -63,7 +65,6 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
[removeToast],
|
||||
);
|
||||
|
||||
// Cleanup all timers on unmount
|
||||
useEffect(() => {
|
||||
const current = timersRef.current;
|
||||
return () => {
|
||||
@@ -87,17 +88,17 @@ export function useToast() {
|
||||
}
|
||||
|
||||
const typeStyles: Record<Toast["type"], string> = {
|
||||
info: "border-l-primary bg-card text-card-foreground",
|
||||
success: "border-l-emerald-500 bg-card text-card-foreground",
|
||||
error: "border-l-destructive bg-card text-card-foreground",
|
||||
warning: "border-l-amber-500 bg-card text-card-foreground",
|
||||
info: "border-l-info bg-white text-info",
|
||||
success: "border-l-success bg-white text-success",
|
||||
error: "border-l-destructive bg-white text-destructive",
|
||||
warning: "border-l-warning bg-white text-warning",
|
||||
};
|
||||
|
||||
const typeIcons: Record<Toast["type"], React.ReactNode> = {
|
||||
info: <Info className="h-4 w-4 text-primary" />,
|
||||
success: <CheckCircle2 className="h-4 w-4 text-emerald-500" />,
|
||||
info: <Info className="h-4 w-4 text-info" />,
|
||||
success: <CheckCircle2 className="h-4 w-4 text-success" />,
|
||||
error: <AlertCircle className="h-4 w-4 text-destructive" />,
|
||||
warning: <AlertTriangle className="h-4 w-4 text-amber-500" />,
|
||||
warning: <AlertTriangle className="h-4 w-4 text-warning" />,
|
||||
};
|
||||
|
||||
function ToastContainer() {
|
||||
@@ -109,7 +110,7 @@ function ToastContainer() {
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2"
|
||||
className="fixed top-4 right-4 z-40 flex flex-col gap-2"
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
@@ -117,7 +118,7 @@ function ToastContainer() {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"group flex items-center gap-2.5 rounded-lg border border-border px-4 py-3 text-sm shadow-md cursor-pointer transition-all hover:scale-[1.02] border-l-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"group flex items-center gap-2.5 rounded-xl border border-[#e0e0e0] px-4 py-3 text-sm shadow-[0_4px_12px_rgba(0,0,0,0.08)] cursor-pointer transition-all duration-200 hover:scale-[1.02] border-l-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
|
||||
typeStyles[toast.type],
|
||||
)}
|
||||
onClick={() => removeToast(toast.id)}
|
||||
@@ -128,10 +129,10 @@ function ToastContainer() {
|
||||
}}
|
||||
>
|
||||
<span className="flex-shrink-0">{typeIcons[toast.type]}</span>
|
||||
<span className="flex-1">{toast.message}</span>
|
||||
<span className="flex-1 font-sans text-sm">{toast.message}</span>
|
||||
<X
|
||||
aria-label="Close notification"
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground md:opacity-0 md:group-hover:opacity-100 transition-opacity"
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-[#999999] md:opacity-0 md:group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,42 +1,170 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
IMPHNEN Design System — Approachable Modernism
|
||||
Manifestasi visual dari semangat komunitas programmer terbesar Indonesia.
|
||||
Dibingkai dengan cinta oleh Cyrene ❄️🌺
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.js";
|
||||
|
||||
/* ─── Google Fonts: Poppins — monofamily yang friendly & percaya diri ─── */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
BASE LAYER — IMPHNEN Design Tokens
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 1 0 0;
|
||||
--foreground: 0.141 0.005 285.823;
|
||||
--card: 1 0 0;
|
||||
--card-foreground: 0.141 0.005 285.823;
|
||||
--primary: 0.623 0.214 259.815;
|
||||
--primary-soft: 0.92 0.04 259.815;
|
||||
--primary-foreground: 0.97 0.014 254.604;
|
||||
--secondary: 0.967 0.001 286.375;
|
||||
--secondary-foreground: 0.21 0.006 285.885;
|
||||
--muted: 0.967 0.001 286.375;
|
||||
--muted-foreground: 0.552 0.016 285.938;
|
||||
--accent: 0.967 0.001 286.375;
|
||||
--accent-foreground: 0.21 0.006 285.885;
|
||||
--destructive: 0.577 0.245 27.325;
|
||||
--destructive-foreground: 0.97 0.014 254.604;
|
||||
--border: 0.92 0.004 286.32;
|
||||
--input: 0.92 0.004 286.32;
|
||||
--ring: 0.623 0.214 259.815;
|
||||
--radius: 1rem;
|
||||
--primary-glow: 0.623 0.214 259.815 / 0.15;
|
||||
--accent-glow: 0.552 0.016 285.938 / 0.15;
|
||||
--card-shadow: 0.92 0.004 286.32 / 0.3;
|
||||
/* ─── Surface & Background ────────────────────────────────────────── */
|
||||
--background: #ffffff;
|
||||
--foreground: #1a1a1a;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #1a1a1a;
|
||||
--muted: #f5f5f5;
|
||||
--muted-foreground: #666666;
|
||||
--accent: #f0f0f0;
|
||||
--accent-foreground: #1a1a1a;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #1a1a1a;
|
||||
|
||||
/* ─── Primary — Signature IMPHNEN Blue (#23a1eb) ─────────────────── */
|
||||
/* Energi, kepercayaan, dan kehangatan digital */
|
||||
--primary: #23a1eb;
|
||||
--primary-foreground: #ffffff;
|
||||
--primary-soft: #e1f0fd;
|
||||
--primary-hover: #1a8fd9;
|
||||
--primary-active: #0877c1;
|
||||
|
||||
/* ─── Secondary — Facebook Integration (#1877f2) ─────────────────── */
|
||||
/* Lebih gelap & saturated untuk hierarki visual */
|
||||
--secondary: #1877f2;
|
||||
--secondary-foreground: #ffffff;
|
||||
--secondary-soft: #e7f1ff;
|
||||
|
||||
/* ─── Tertiary — Discord Accent (#5865f2) ────────────────────────── */
|
||||
/* Kehadiran brand di ekosistem platform */
|
||||
--tertiary: #5865f2;
|
||||
--tertiary-foreground: #ffffff;
|
||||
--tertiary-soft: #eef0ff;
|
||||
|
||||
/* ─── Semantic Colors ────────────────────────────────────────────── */
|
||||
--success: #22c55e;
|
||||
--success-soft: #dcfce7;
|
||||
--warning: #f59e0b;
|
||||
--warning-soft: #fef3c7;
|
||||
--destructive: #e4405f;
|
||||
--destructive-foreground: #ffffff;
|
||||
--destructive-soft: #ffebee;
|
||||
--info: #3b82f6;
|
||||
--info-soft: #dbeafe;
|
||||
|
||||
/* ─── Borders & Inputs ───────────────────────────────────────────── */
|
||||
--border: #e0e0e0;
|
||||
--border-hover: #cccccc;
|
||||
--input: #e0e0e0;
|
||||
--ring: #23a1eb;
|
||||
|
||||
/* ─── Radius — Friendly Geometry ─────────────────────────────────── */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius: 0.5rem;
|
||||
--radius-md: 0.75rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-xl: 1.5rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ─── Elevation — Subtle Shadow Stack ────────────────────────────── */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.12);
|
||||
|
||||
/* ─── Glow — Branded Auroras ─────────────────────────────────────── */
|
||||
--primary-glow: rgba(35, 161, 235, 0.15);
|
||||
--tertiary-glow: rgba(88, 101, 242, 0.15);
|
||||
--destructive-glow: rgba(228, 64, 95, 0.15);
|
||||
--success-glow: rgba(34, 197, 94, 0.15);
|
||||
|
||||
/* ─── Spacing — Rhythm System ────────────────────────────────────── */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 12px;
|
||||
--space-md: 24px;
|
||||
--space-lg: 40px;
|
||||
--space-xl: 64px;
|
||||
--gutter: 24px;
|
||||
--container-max: 1280px;
|
||||
|
||||
/* ─── Transition — Snappy & Responsive ──────────────────────────── */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* ─── Dark Mode Theme ──────────────────────────────────────────────────── */
|
||||
[data-theme="dark"] {
|
||||
--background: #1c1c1f;
|
||||
--foreground: #f0f0f2;
|
||||
--card: #1c1c1f;
|
||||
--card-foreground: #f0f0f2;
|
||||
--muted: #141417;
|
||||
--muted-foreground: #a0a0a6;
|
||||
--accent: #26262a;
|
||||
--accent-foreground: #f0f0f2;
|
||||
--popover: #1c1c1f;
|
||||
--popover-foreground: #f0f0f2;
|
||||
--primary: #54a2ff;
|
||||
--primary-foreground: #0d0d0f;
|
||||
--primary-soft: #18263a;
|
||||
--primary-hover: #3d8ee8;
|
||||
--primary-active: #2a7ad4;
|
||||
--secondary: #4a8ef5;
|
||||
--secondary-foreground: #0d0d0f;
|
||||
--secondary-soft: #1a274a;
|
||||
--tertiary: #7984f5;
|
||||
--tertiary-foreground: #0d0d0f;
|
||||
--tertiary-soft: #20266a;
|
||||
--success: #34d399;
|
||||
--success-soft: #13261a;
|
||||
--warning: #fbbf24;
|
||||
--warning-soft: #261a10;
|
||||
--destructive: #f87171;
|
||||
--destructive-soft: #2a1418;
|
||||
--info: #60a5fa;
|
||||
--info-soft: #141e38;
|
||||
--border: #343438;
|
||||
--border-hover: #48484d;
|
||||
--input: #343438;
|
||||
--ring: #54a2ff;
|
||||
--outline: #6a6a70;
|
||||
--outline-variant: #404044;
|
||||
--primary-glow: rgba(84,162,255,0.15);
|
||||
--tertiary-glow: rgba(121,132,245,0.15);
|
||||
--destructive-glow: rgba(248,113,113,0.15);
|
||||
--success-glow: rgba(52,211,153,0.15);
|
||||
--surface: #1c1c1f;
|
||||
--surface-dim: #141417;
|
||||
--surface-bright: #2c2c30;
|
||||
--on-surface: #f0f0f2;
|
||||
--on-surface-variant: #a0a0a6;
|
||||
--inverse-surface: #f0f0f2;
|
||||
--inverse-on-surface: #1c1c1f;
|
||||
--surface-container: #26262a;
|
||||
--surface-container-low: #202023;
|
||||
--surface-container-high: #2c2c30;
|
||||
--surface-container-highest: #323236;
|
||||
}
|
||||
|
||||
/* ─── Global Resets ──────────────────────────────────────────────────── */
|
||||
* {
|
||||
border-color: oklch(var(--border));
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: oklch(var(--background));
|
||||
color: oklch(var(--foreground));
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-family: Poppins, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: 'Poppins', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -44,80 +172,477 @@
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* ─── Smooth Scroll ──────────────────────────────────────────────────── */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* ─── Selection ──────────────────────────────────────────────────────── */
|
||||
::selection {
|
||||
background-color: var(--primary-soft);
|
||||
color: var(--on-primary-container, #0d4a7a);
|
||||
}
|
||||
|
||||
/* ─── Theme Transition ─────────────────────────────────────────────────── */
|
||||
html.theme-transitioning,
|
||||
html.theme-transitioning *,
|
||||
html.theme-transitioning *::before,
|
||||
html.theme-transitioning *::after {
|
||||
transition: background-color 200ms ease, color 150ms ease, border-color 150ms ease, box-shadow 200ms ease !important;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.glass-card {
|
||||
@apply bg-white/70 backdrop-blur-sm border border-[oklch(0.92_0.004_286.32)] rounded-xl;
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
THEME EXTENSION — IMPHNEN Custom Tailwind Tokens
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
@theme {
|
||||
/* ─── Typography Scale — Poppins Monofamily ─────────────────────────── */
|
||||
--font-family-display: 'Poppins', sans-serif;
|
||||
--font-size-display: 60px;
|
||||
--font-weight-display: 700;
|
||||
--line-height-display: 68px;
|
||||
--letter-spacing-display: -0.04em;
|
||||
|
||||
--font-size-headline-lg: 40px;
|
||||
--font-weight-headline-lg: 600;
|
||||
--line-height-headline-lg: 48px;
|
||||
--letter-spacing-headline-lg: -0.02em;
|
||||
|
||||
--font-size-headline-md: 28px;
|
||||
--font-weight-headline-md: 600;
|
||||
--line-height-headline-md: 36px;
|
||||
--letter-spacing-headline-md: -0.01em;
|
||||
|
||||
--font-size-title-lg: 20px;
|
||||
--font-weight-title-lg: 600;
|
||||
--line-height-title-lg: 28px;
|
||||
|
||||
--font-size-body-lg: 18px;
|
||||
--font-weight-body-lg: 400;
|
||||
--line-height-body-lg: 28px;
|
||||
--letter-spacing-body-lg: 0.01em;
|
||||
|
||||
--font-size-body-md: 16px;
|
||||
--font-weight-body-md: 400;
|
||||
--line-height-body-md: 24px;
|
||||
--letter-spacing-body-md: 0.01em;
|
||||
|
||||
--font-size-label-md: 14px;
|
||||
--font-weight-label-md: 600;
|
||||
--line-height-label-md: 20px;
|
||||
--letter-spacing-label-md: 0.02em;
|
||||
|
||||
--font-size-label-sm: 12px;
|
||||
--font-weight-label-sm: 500;
|
||||
--line-height-label-sm: 16px;
|
||||
--letter-spacing-label-sm: 0.03em;
|
||||
|
||||
/* ─── Brand Colors ──────────────────────────────────────────────────── */
|
||||
--color-primary: #23a1eb;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-primary-soft: #e1f0fd;
|
||||
--color-primary-hover: #1a8fd9;
|
||||
--color-primary-active: #0877c1;
|
||||
|
||||
--color-secondary: #1877f2;
|
||||
--color-secondary-foreground: #ffffff;
|
||||
--color-secondary-soft: #e7f1ff;
|
||||
|
||||
--color-tertiary: #5865f2;
|
||||
--color-tertiary-foreground: #ffffff;
|
||||
--color-tertiary-soft: #eef0ff;
|
||||
|
||||
--color-success: #22c55e;
|
||||
--color-success-soft: #dcfce7;
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-soft: #fef3c7;
|
||||
--color-destructive: #e4405f;
|
||||
--color-destructive-soft: #ffebee;
|
||||
--color-info: #3b82f6;
|
||||
--color-info-soft: #dbeafe;
|
||||
|
||||
/* ─── Surface Bridge ────────────────────────────────────────────────── */
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-dim: #f5f5f5;
|
||||
--color-surface-bright: #ffffff;
|
||||
--color-on-surface: #1a1a1a;
|
||||
--color-on-surface-variant: #666666;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
COMPONENT LAYER — IMPHNEN Design Patterns
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
@layer components {
|
||||
/* ─── Glass Morphism ────────────────────────────────────────────────── */
|
||||
.im-surface {
|
||||
@apply bg-white border border-[#e0e0e0] rounded-xl;
|
||||
}
|
||||
|
||||
.grid-pattern {
|
||||
.im-glass {
|
||||
@apply bg-white/70 backdrop-blur-sm border border-[#e0e0e0]/60 rounded-xl;
|
||||
}
|
||||
|
||||
.im-glass-strong {
|
||||
@apply bg-white/85 backdrop-blur-md border border-[#e0e0e0]/80 rounded-xl shadow-sm;
|
||||
}
|
||||
|
||||
/* ─── Gradient Text ─────────────────────────────────────────────────── */
|
||||
.im-gradient-text {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-[#23a1eb] to-[#1877f2];
|
||||
}
|
||||
|
||||
.im-gradient-text-warm {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-[#23a1eb] to-[#5865f2];
|
||||
}
|
||||
|
||||
/* ─── IMPHNEN Button Base ────────────────────────────────────────────── */
|
||||
.im-btn {
|
||||
@apply inline-flex items-center justify-center gap-2 font-semibold;
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px 24px;
|
||||
height: 44px;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.im-btn-primary {
|
||||
@apply im-btn text-white;
|
||||
background-color: var(--primary);
|
||||
}
|
||||
.im-btn-primary:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
.im-btn-primary:active {
|
||||
background-color: var(--primary-active);
|
||||
}
|
||||
|
||||
.im-btn-secondary {
|
||||
@apply im-btn;
|
||||
background-color: transparent;
|
||||
color: var(--primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.im-btn-secondary:hover {
|
||||
background-color: var(--accent);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.im-btn-ghost {
|
||||
@apply im-btn;
|
||||
background-color: transparent;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.im-btn-ghost:hover {
|
||||
background-color: var(--accent);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* ─── Badge / Pill ──────────────────────────────────────────────────── */
|
||||
.im-badge {
|
||||
@apply inline-flex items-center;
|
||||
border-radius: var(--radius-full);
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.03em;
|
||||
line-height: 16px;
|
||||
background-color: var(--primary-soft);
|
||||
color: var(--on-primary-container, #0d4a7a);
|
||||
}
|
||||
|
||||
.im-badge-secondary {
|
||||
background-color: var(--secondary-soft);
|
||||
color: var(--on-secondary-container, #003d99);
|
||||
}
|
||||
|
||||
.im-badge-tertiary {
|
||||
background-color: var(--tertiary-soft);
|
||||
color: var(--on-tertiary-container, #1a2466);
|
||||
}
|
||||
|
||||
.im-badge-success {
|
||||
background-color: var(--success-soft);
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.im-badge-warning {
|
||||
background-color: var(--warning-soft);
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.im-badge-destructive {
|
||||
background-color: var(--destructive-soft);
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
/* ─── Card ──────────────────────────────────────────────────────────── */
|
||||
.im-card {
|
||||
background-color: var(--card);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all var(--transition-slow);
|
||||
}
|
||||
.im-card:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* ─── Input ─────────────────────────────────────────────────────────── */
|
||||
.im-input {
|
||||
background-color: var(--muted);
|
||||
color: var(--foreground);
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
transition: border-color var(--transition-fast);
|
||||
width: 100%;
|
||||
font-family: 'Poppins', sans-serif;
|
||||
}
|
||||
.im-input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-glow);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ─── Nav Link ──────────────────────────────────────────────────────── */
|
||||
.im-nav-link {
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 8px 12px;
|
||||
position: relative;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
.im-nav-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
.im-nav-link.active {
|
||||
color: var(--primary);
|
||||
}
|
||||
.im-nav-link.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
height: 2px;
|
||||
background-color: var(--primary);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* ─── Grid Pattern Background ───────────────────────────────────────── */
|
||||
.im-grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px),
|
||||
linear-gradient(90deg, oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px);
|
||||
linear-gradient(rgba(0, 0, 0, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.03) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
UTILITY LAYER ─── Animations & Effects
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
@layer utilities {
|
||||
/* ─── Entry Animations ──────────────────────────────────────────────── */
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bar-pulse {
|
||||
0%, 100% {
|
||||
transform: scaleY(0.8);
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.3s ease-out;
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1.2);
|
||||
.animate-slide-in-right {
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
/* ─── Pulse & Shimmer ───────────────────────────────────────────────── */
|
||||
.animate-bar-pulse {
|
||||
animation: barPulse 0.4s ease-in-out infinite;
|
||||
transform-origin: bottom;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0.06) 0%,
|
||||
rgba(0, 0, 0, 0.02) 40%,
|
||||
rgba(0, 0, 0, 0.06) 80%,
|
||||
rgba(0, 0, 0, 0.08) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ─── Status Indicators ─────────────────────────────────────────────── */
|
||||
.im-status-dot {
|
||||
@apply inline-block h-2 w-2 rounded-full;
|
||||
}
|
||||
.im-status-dot-success {
|
||||
@apply im-status-dot bg-[#22c55e];
|
||||
}
|
||||
.im-status-dot-warning {
|
||||
@apply im-status-dot bg-[#f59e0b];
|
||||
}
|
||||
.im-status-dot-error {
|
||||
@apply im-status-dot bg-[#e4405f];
|
||||
}
|
||||
.im-status-dot-idle {
|
||||
@apply im-status-dot bg-[#cccccc];
|
||||
}
|
||||
|
||||
/* ─── Divider ───────────────────────────────────────────────────────── */
|
||||
.im-divider {
|
||||
@apply w-full border-t;
|
||||
border-color: var(--border);
|
||||
}
|
||||
.im-divider-label {
|
||||
@apply flex items-center gap-3 text-xs font-medium text-[#999999];
|
||||
}
|
||||
.im-divider-label::before,
|
||||
.im-divider-label::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ─── Typography Utilities ──────────────────────────────────────────── */
|
||||
.typo-display {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: 60px;
|
||||
font-weight: 700;
|
||||
line-height: 68px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.typo-headline-lg {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
line-height: 48px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.typo-headline-md {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
line-height: 36px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.typo-title-lg {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
}
|
||||
.typo-body-lg {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 28px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.typo-body-md {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.typo-label-md {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.typo-label-sm {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
KEYFRAMES ─── Animations
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-bar-pulse {
|
||||
animation: bar-pulse 0.4s ease-in-out infinite;
|
||||
transform-origin: bottom;
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
oklch(0.92 0.004 286.32 / 0.5) 0%,
|
||||
oklch(0.967 0.001 286.375) 40%,
|
||||
oklch(0.92 0.004 286.32 / 0.5) 80%,
|
||||
oklch(0.92 0.004 286.32 / 0.7) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
@keyframes slideInRight {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes barPulse {
|
||||
0%, 100% { transform: scaleY(0.8); }
|
||||
50% { transform: scaleY(1.2); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes glowPulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
/* ─── IMPHNEN Mascot Wiggle ───────────────────────────────────────────── */
|
||||
@keyframes mascotWiggle {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
15% { transform: rotate(-8deg); }
|
||||
30% { transform: rotate(6deg); }
|
||||
45% { transform: rotate(-4deg); }
|
||||
60% { transform: rotate(2deg); }
|
||||
}
|
||||
|
||||
.animate-mascot-wiggle {
|
||||
animation: mascotWiggle 0.6s ease-in-out;
|
||||
}
|
||||
|
||||
/* ─── IMPHNEN Notification Pulse ──────────────────────────────────────── */
|
||||
@keyframes notificationPulse {
|
||||
0% { box-shadow: 0 0 0 0 var(--primary-glow); }
|
||||
70% { box-shadow: 0 0 0 8px transparent; }
|
||||
100% { box-shadow: 0 0 0 0 transparent; }
|
||||
}
|
||||
|
||||
.animate-notification-pulse {
|
||||
animation: notificationPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
ACCESSIBILITY ─── Reduced Motion
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
|
||||
@@ -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