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:
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user