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