feat(admin): design system audit fixes

- Add CSS design tokens for consistency (--admin-danger-hover, --admin-accent-dot, shadow-md warm tone)
- Replace unicode icons with inline SVG (⋮, ✕, ▼, ▶)
- Consolidate duplicate PageHeader/AdminPageHeader components
- Standardize border-radius (rounded-xl → rounded-lg for ViewModeTabs)
- Remove hardcoded brand UUID in products page
- Replace hardcoded bg-red-600 with CSS variables in delete buttons
- Add AdminButton, AdminFilterTabs, AdminSearchInput, PageHeader design system components
- Fix GlassModal and AdminModal close button styling
- Remove duplicate 'New Lot' button on Route Trace dashboard
This commit is contained in:
2026-06-02 03:31:54 +00:00
parent 15e939ad7e
commit 7203cf1ead
65 changed files with 3478 additions and 3176 deletions
@@ -16,7 +16,7 @@ type AdminActionMenuProps = {
className?: string;
};
export default function AdminActionMenu({ actions, triggerLabel = "⋮", className = "" }: AdminActionMenuProps) {
export default function AdminActionMenu({ actions, triggerLabel, className = "" }: AdminActionMenuProps) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@@ -41,7 +41,9 @@ export default function AdminActionMenu({ actions, triggerLabel = "⋮", classNa
onClick={() => setOpen(!open)}
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
{triggerLabel}
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
</svg>
</button>
{open && (
@@ -0,0 +1,135 @@
"use client";
import { ReactNode, ButtonHTMLAttributes } from "react";
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
type ButtonSize = "sm" | "md" | "lg";
type AdminButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: ReactNode;
iconPosition?: "left" | "right";
isLoading?: boolean;
fullWidth?: boolean;
};
const variantClasses: Record<ButtonVariant, string> = {
primary: `
bg-[var(--admin-accent)] text-white border border-transparent
hover:bg-[var(--admin-accent-hover)]
active:bg-[var(--admin-accent-hover)]
`,
secondary: `
bg-[var(--admin-card-bg)] text-[var(--admin-text-secondary)] border border-[var(--admin-border)]
hover:bg-[var(--admin-bg)] hover:border-[var(--admin-text-muted)]
active:bg-[var(--admin-bg)]
`,
danger: `
bg-[var(--admin-danger)] text-white border border-transparent
hover:bg-[var(--admin-danger-hover)]
active:bg-[var(--admin-danger-hover)]
`,
ghost: `
bg-transparent text-[var(--admin-text-secondary)] border border-transparent
hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)]
active:bg-[var(--admin-bg-subtle)]
`,
};
const sizeClasses: Record<ButtonSize, string> = {
sm: "px-3 py-1.5 text-xs rounded-lg gap-1.5",
md: "px-4 py-2 text-sm rounded-xl gap-2",
lg: "px-5 py-3 text-base rounded-xl gap-2",
};
export default function AdminButton({
children,
variant = "primary",
size = "md",
icon,
iconPosition = "left",
isLoading = false,
fullWidth = false,
className = "",
disabled,
...props
}: AdminButtonProps) {
const baseClasses = `
inline-flex items-center justify-center font-semibold
transition-all duration-200 cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed
shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
`;
const variantClass = variantClasses[variant];
const sizeClass = sizeClasses[size];
const widthClass = fullWidth ? "w-full" : "";
const content = (
<>
{isLoading && (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
{!isLoading && icon && iconPosition === "left" && (
<span className="flex-shrink-0">{icon}</span>
)}
{children && <span>{children}</span>}
{!isLoading && icon && iconPosition === "right" && (
<span className="flex-shrink-0">{icon}</span>
)}
</>
);
return (
<button
className={`${baseClasses} ${variantClass} ${sizeClass} ${widthClass} ${className}`}
disabled={disabled || isLoading}
{...props}
>
{content}
</button>
);
}
// Icon Button variant for icon-only buttons
type AdminIconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md" | "lg";
label: string; // For accessibility
};
const iconSizeClasses: Record<ButtonSize, string> = {
sm: "p-1.5",
md: "p-2",
lg: "p-2.5",
};
export function AdminIconButton({
variant = "ghost",
size = "md",
label,
className = "",
...props
}: AdminIconButtonProps) {
const baseClasses = `
inline-flex items-center justify-center rounded-lg
transition-all duration-150 cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed
`;
const variantClass = variantClasses[variant];
const sizeClass = iconSizeClasses[size];
return (
<button
className={`${baseClasses} ${variantClass} ${sizeClass} ${className}`}
aria-label={label}
title={label}
{...props}
/>
);
}
@@ -0,0 +1,252 @@
"use client";
type TabItem = {
/** Unique value for the tab */
value: string;
/** Display label for the tab */
label: string;
/** Optional count badge */
count?: number;
/** Optional icon element */
icon?: React.ReactNode;
/** Disable this tab */
disabled?: boolean;
};
type AdminFilterTabsProps = {
/** Currently active tab value */
activeTab: string;
/** Callback when tab changes */
onTabChange: (value: string) => void;
/** Array of tab items */
tabs: TabItem[];
/** CSS class for the tabs container */
className?: string;
/** Size variant */
size?: "sm" | "md";
/** Show count badges */
showCounts?: boolean;
};
const sizeClasses = {
sm: {
container: "p-0.5 gap-0.5",
tab: "px-2.5 py-1.5 text-[10px]",
},
md: {
container: "p-1 gap-0.5",
tab: "px-3 py-1.5 text-xs",
},
};
export default function AdminFilterTabs({
activeTab,
onTabChange,
tabs,
className = "",
size = "md",
showCounts = true,
}: AdminFilterTabsProps) {
const sizes = sizeClasses[size];
return (
<div
className={`
flex rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]
${sizes.container} ${className}
`}
role="tablist"
aria-label="Filter tabs"
>
{tabs.map((tab) => {
const isActive = activeTab === tab.value;
const isDisabled = tab.disabled ?? false;
return (
<button
key={tab.value}
type="button"
role="tab"
aria-selected={isActive}
disabled={isDisabled}
onClick={() => !isDisabled && onTabChange(tab.value)}
className={`
flex items-center gap-1.5 rounded-lg font-semibold transition-all duration-150
disabled:opacity-50 disabled:cursor-not-allowed
${sizes.tab}
${isActive
? `
bg-white text-emerald-700
border border-emerald-200
shadow-sm
`
: `
text-stone-600
hover:text-emerald-700
hover:bg-emerald-50/50
`
}
`}
>
{/* Tab Icon */}
{tab.icon && (
<span className={`flex-shrink-0 ${isActive ? "text-emerald-600" : "text-stone-400"}`}>
{tab.icon}
</span>
)}
{/* Tab Label */}
<span>{tab.label}</span>
{/* Count Badge */}
{showCounts && tab.count !== undefined && (
<span
className={`
ml-0.5 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full px-1
text-[10px] font-bold
${isActive
? "bg-[var(--admin-accent)] text-white"
: "bg-[var(--admin-border)] text-[var(--admin-text-muted)]"
}
`}
>
{tab.count}
</span>
)}
</button>
);
})}
</div>
);
}
// Convenience components for common tab configurations
export type StatusFilter = "all" | "active" | "inactive" | "pending" | "draft" | string;
type StatusFilterTabsProps = {
activeTab: StatusFilter;
onTabChange: (value: StatusFilter) => void;
counts?: {
all?: number;
active?: number;
inactive?: number;
pending?: number;
draft?: number;
};
className?: string;
size?: "sm" | "md";
};
export function AdminStatusFilterTabs({
activeTab,
onTabChange,
counts,
className = "",
size = "md",
}: StatusFilterTabsProps) {
const defaultTabs: TabItem[] = [
{ value: "all", label: "All", count: counts?.all },
{ value: "active", label: "Active", count: counts?.active },
{ value: "pending", label: "Pending", count: counts?.pending },
{ value: "draft", label: "Draft", count: counts?.draft },
{ value: "inactive", label: "Inactive", count: counts?.inactive },
];
return (
<AdminFilterTabs
activeTab={activeTab}
onTabChange={onTabChange}
tabs={defaultTabs}
className={className}
size={size}
/>
);
}
// View mode tabs (table/card view)
type ViewMode = "table" | "cards" | "list" | string;
type ViewModeTabsProps = {
activeTab: ViewMode;
onTabChange: (value: ViewMode) => void;
showTable?: boolean;
showCards?: boolean;
showList?: boolean;
className?: string;
};
const TableIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
);
const CardsIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
);
const ListIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
);
export function AdminViewModeTabs({
activeTab,
onTabChange,
showTable = true,
showCards = true,
showList = false,
className = "",
}: ViewModeTabsProps) {
const tabs: TabItem[] = [];
if (showTable) tabs.push({ value: "table", label: "Table", icon: <TableIcon /> });
if (showCards) tabs.push({ value: "cards", label: "Cards", icon: <CardsIcon /> });
if (showList) tabs.push({ value: "list", label: "List", icon: <ListIcon /> });
return (
<div
className={`
flex rounded-lg border border-[var(--admin-border)] bg-white p-1
${className}
`}
role="tablist"
aria-label="View mode"
>
{tabs.map((tab) => {
const isActive = activeTab === tab.value;
return (
<button
key={tab.value}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => onTabChange(tab.value)}
className={`
flex items-center justify-center rounded-lg p-1.5 transition-all duration-150
${isActive
? `
bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]
border border-[var(--admin-accent)]
shadow-[var(--admin-shadow-sm)]
`
: `
text-[var(--admin-text-muted)]
hover:text-[var(--admin-text-secondary)]
hover:bg-[var(--admin-bg-subtle)]
`
}
`}
>
{tab.icon}
</button>
);
})}
</div>
);
}
@@ -33,72 +33,71 @@ export function AdminInput({
}
// Styled text input wrapper
type AdminTextInputProps = {
value: string;
type AdminTextInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'type'> & {
value: string | number;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
type?: string;
disabled?: boolean;
className?: string;
};
export function AdminTextInput({
value,
onChange,
placeholder,
type = "text",
disabled,
className = ""
className = "",
maxLength,
autoComplete,
step,
min,
max,
...rest
}: AdminTextInputProps) {
return (
<input
type={type}
value={value}
onChange={onChange}
placeholder={placeholder}
disabled={disabled}
className={`w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] placeholder:text-[var(--admin-text-muted)] disabled:bg-[var(--admin-bg-subtle)] disabled:text-[var(--admin-text-muted)] ${className}`}
className={`w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] disabled:opacity-50 ${className}`}
maxLength={maxLength}
autoComplete={autoComplete}
step={step}
min={min}
max={max}
{...rest}
/>
);
}
// Styled textarea wrapper
type AdminTextareaProps = {
value: string;
type AdminTextareaProps = Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange' | 'value'> & {
value: string | number;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
placeholder?: string;
rows?: number;
disabled?: boolean;
className?: string;
};
export function AdminTextarea({
value,
onChange,
placeholder,
rows = 3,
disabled,
className = ""
className = "",
...rest
}: AdminTextareaProps) {
return (
<textarea
value={value}
onChange={onChange}
placeholder={placeholder}
rows={rows}
disabled={disabled}
className={`w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] placeholder:text-[var(--admin-text-muted)] resize-none disabled:bg-[var(--admin-bg-subtle)] disabled:text-[var(--admin-text-muted)] ${className}`}
className={`w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] resize-none disabled:opacity-50 ${className}`}
{...rest}
/>
);
}
// Styled select wrapper
type AdminSelectProps = {
value: string;
type AdminSelectProps = Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'onChange' | 'value'> & {
value: string | number;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
options: { value: string; label: string }[];
disabled?: boolean;
className?: string;
};
export function AdminSelect({
@@ -106,14 +105,16 @@ export function AdminSelect({
onChange,
options,
disabled,
className = ""
className = "",
...rest
}: AdminSelectProps) {
return (
<select
value={value}
onChange={onChange}
disabled={disabled}
className={`w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] disabled:bg-[var(--admin-bg-subtle)] disabled:text-[var(--admin-text-muted)] ${className}`}
className={`w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 disabled:opacity-50 ${className}`}
{...rest}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
@@ -147,7 +148,7 @@ export function AdminCheckbox({
checked={checked}
onChange={onChange}
disabled={disabled}
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-[var(--admin-accent)]"
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/30 focus:ring-offset-1 cursor-pointer"
/>
<span className="text-sm font-medium text-[var(--admin-text-secondary)]">{label}</span>
</label>
@@ -33,7 +33,7 @@ export default function AdminModal({ title, subtitle, onClose, children, maxWidt
<div
className={`relative w-full ${maxWidth} rounded-2xl`}
style={{
backgroundColor: "#ffffff",
backgroundColor: "var(--admin-card-bg)",
border: "1px solid var(--admin-border)",
boxShadow: "0 25px 50px -12px rgba(60, 56, 37, 0.35), 0 12px 24px -8px rgba(60, 56, 37, 0.2)",
}}
@@ -1,39 +1,20 @@
"use client";
import Link from "next/link";
import PageHeader from "./PageHeader";
import { ReactNode } from "react";
// Re-export PageHeader with description prop mapped to subtitle for backward compatibility
type AdminPageHeaderProps = {
breadcrumb?: { label: string; href?: string }[];
icon?: ReactNode;
title: string;
description?: string;
actions?: ReactNode;
className?: string;
};
export default function AdminPageHeader({ breadcrumb, title, description, actions }: AdminPageHeaderProps) {
return (
<div className="mb-6">
{breadcrumb && breadcrumb.length > 0 && (
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-4">
{breadcrumb.map((item, i) => (
<span key={i} className="flex items-center gap-2">
{item.href ? (
<Link href={item.href} className="hover:text-[var(--admin-text-secondary)] transition-colors">{item.label}</Link>
) : (
<span className="text-[var(--admin-text-secondary)]">{item.label}</span>
)}
{i < breadcrumb.length - 1 && <span>/</span>}
</span>
))}
</nav>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-[var(--admin-text-primary)] tracking-tight">{title}</h1>
{description && <p className="mt-1 text-sm text-[var(--admin-text-muted)]">{description}</p>}
</div>
{actions && <div className="flex items-center gap-3">{actions}</div>}
</div>
</div>
);
export default function AdminPageHeader(props: AdminPageHeaderProps) {
// Convert description to subtitle for PageHeader
const { description, ...rest } = props;
return <PageHeader {...rest} subtitle={description} />;
}
@@ -0,0 +1,118 @@
"use client";
import { InputHTMLAttributes, forwardRef } from "react";
type AdminSearchInputProps = InputHTMLAttributes<HTMLInputElement> & {
/** Search icon element (defaults to magnifying glass) */
icon?: React.ReactNode;
/** Icon position */
iconPosition?: "left" | "right";
/** Callback when clear button is clicked */
onClear?: () => void;
/** Show clear button when value exists */
showClear?: boolean;
/** Container CSS class */
containerClassName?: string;
/** Input wrapper CSS class */
inputClassName?: string;
};
const SearchIcon = () => (
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
);
const ClearIcon = () => (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
);
const AdminSearchInput = forwardRef<HTMLInputElement, AdminSearchInputProps>(({
icon,
iconPosition = "left",
onClear,
showClear = true,
containerClassName = "",
inputClassName = "",
value,
className = "",
...props
}, ref) => {
const searchIcon = icon ?? <SearchIcon />;
const hasValue = value !== undefined && value !== "";
const shouldShowClear = showClear && hasValue && onClear;
const inputBaseClasses = `
w-full rounded-xl border border-[var(--admin-border)] bg-white
py-2 pl-10 pr-4 text-sm text-[var(--admin-text-primary)]
outline-none transition-colors duration-150
placeholder:text-[var(--admin-text-muted)]
focus:border-[var(--admin-accent)]
disabled:bg-[var(--admin-bg-subtle)] disabled:cursor-not-allowed
`;
return (
<div className={`relative flex-1 min-w-[12rem] ${containerClassName}`}>
{/* Left Icon */}
{iconPosition === "left" && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)] pointer-events-none">
{searchIcon}
</div>
)}
{/* Input */}
<input
ref={ref}
type="search"
value={value}
className={`${inputBaseClasses} ${inputClassName} ${className}`}
{...props}
/>
{/* Right Icon (Clear button or custom) */}
{iconPosition === "right" && !shouldShowClear && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)] pointer-events-none">
{searchIcon}
</div>
)}
{/* Clear Button */}
{shouldShowClear && (
<button
type="button"
onClick={onClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] transition-colors p-0.5"
aria-label="Clear search"
>
<ClearIcon />
</button>
)}
</div>
);
});
AdminSearchInput.displayName = "AdminSearchInput";
export default AdminSearchInput;
@@ -0,0 +1,84 @@
"use client";
import Link from "next/link";
import { ReactNode } from "react";
type PageHeaderProps = {
/** Breadcrumb navigation items */
breadcrumb?: { label: string; href?: string }[];
/** Icon displayed before the title */
icon?: ReactNode;
/** Main title text */
title: string;
/** Optional subtitle/description below the title */
subtitle?: string;
/** Optional action buttons/elements to display on the right */
actions?: ReactNode;
/** Additional CSS classes */
className?: string;
};
export default function PageHeader({
breadcrumb,
icon,
title,
subtitle,
actions,
className = "",
}: PageHeaderProps) {
return (
<div className={`mb-6 ${className}`}>
{/* Breadcrumb Navigation */}
{breadcrumb && breadcrumb.length > 0 && (
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-4">
{breadcrumb.map((crumb, index) => (
<span key={index} className="flex items-center gap-2">
{crumb.href ? (
<Link
href={crumb.href}
className="hover:text-[var(--admin-text-secondary)] transition-colors"
>
{crumb.label}
</Link>
) : (
<span className="text-[var(--admin-text-secondary)]">{crumb.label}</span>
)}
{index < breadcrumb.length - 1 && (
<span className="text-[var(--admin-text-muted)]">/</span>
)}
</span>
))}
</nav>
)}
{/* Title Row with Icon and Actions */}
<div className="flex items-center justify-between">
{/* Left: Icon + Title + Subtitle */}
<div className="flex items-center gap-4">
{icon && (
<div className="flex items-center justify-center w-12 h-12 rounded-2xl bg-[var(--admin-accent-light)] text-[var(--admin-accent)] shadow-[var(--admin-shadow-sm)]">
{icon}
</div>
)}
<div>
<h1 className="text-2xl font-bold text-[var(--admin-text-primary)] tracking-tight">
{title}
</h1>
{subtitle && (
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
{subtitle}
</p>
)}
</div>
</div>
{/* Right: Action Buttons */}
{actions && (
<div className="flex items-center gap-3">
{actions}
</div>
)}
</div>
</div>
);
}
+7 -4
View File
@@ -12,11 +12,14 @@ export { default as AdminActionMenu, AdminActionButton } from "./AdminActionMenu
export { default as AdminPagination, AdminSimplePagination } from "./AdminPagination";
export { default as AdminBadge, AdminStatusBadge, AdminCountBadge } from "./AdminBadge";
// Design system components
export { default as AdminButton, AdminIconButton } from "./AdminButton";
export { default as PageHeader } from "./PageHeader";
export { default as AdminSearchInput } from "./AdminSearchInput";
export { default as AdminFilterTabs, AdminStatusFilterTabs, AdminViewModeTabs } from "./AdminFilterTabs";
// Form elements
export { AdminInput, AdminTextInput, AdminTextarea, AdminSelect, AdminCheckbox, AdminSpinner, AdminLoadingOverlay } from "./AdminFormElements";
// Modal component
export { default as AdminModal } from "./AdminModal";
// Re-export GlassModal for backward compatibility
// Modal component - GlassModal is the standard modal (max-w-lg default), AdminModal is a smaller variant (max-w-md default)
export { default as GlassModal } from "@/components/admin/GlassModal";