Admin AI Tools page: unified design system styling, provider selector with OpenAI turd emoji, free-text model input with exact ID warning
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type Action = {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "danger" | "success";
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type AdminActionMenuProps = {
|
||||
actions: Action[];
|
||||
triggerLabel?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AdminActionMenu({ actions, triggerLabel = "⋮", className = "" }: AdminActionMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleAction = (action: Action) => {
|
||||
action.onClick();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className={`relative inline-flex items-center justify-end ${className}`}>
|
||||
<button
|
||||
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}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-[var(--admin-shadow-lg)] overflow-hidden">
|
||||
{actions.map((action, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleAction(action)}
|
||||
disabled={action.disabled}
|
||||
className={`w-full text-left px-4 py-2.5 text-sm transition-colors ${
|
||||
action.variant === "danger"
|
||||
? "text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)]"
|
||||
: action.variant === "success"
|
||||
? "text-[var(--admin-accent-text)] hover:bg-[var(--admin-accent-light)]"
|
||||
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
|
||||
} ${action.disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple inline action button
|
||||
type AdminActionButtonProps = {
|
||||
children: ReactNode;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "primary" | "danger";
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminActionButton({
|
||||
children,
|
||||
onClick,
|
||||
variant = "default",
|
||||
size = "sm",
|
||||
className = ""
|
||||
}: AdminActionButtonProps) {
|
||||
const baseClasses = "rounded-lg font-medium transition-colors";
|
||||
const sizeClasses = size === "sm" ? "px-3 py-1.5 text-xs" : "px-4 py-2 text-sm";
|
||||
|
||||
const variantClasses = {
|
||||
default: "text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)]",
|
||||
primary: "bg-[var(--admin-accent)] text-white hover:opacity-90",
|
||||
danger: "text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)]",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`${baseClasses} ${sizeClasses} ${variantClasses[variant]} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
type BadgeVariant = "default" | "success" | "warning" | "danger" | "info";
|
||||
|
||||
type AdminBadgeProps = {
|
||||
children: React.ReactNode;
|
||||
variant?: BadgeVariant;
|
||||
dot?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const variantClasses: Record<BadgeVariant, { bg: string; text: string; dot: string }> = {
|
||||
default: { bg: "bg-[var(--admin-border-light)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-text-muted)]" },
|
||||
success: { bg: "bg-[var(--admin-accent-light)]", text: "text-[var(--admin-accent-text)]", dot: "bg-[var(--admin-accent)]" },
|
||||
warning: { bg: "bg-[var(--admin-warning-light)]", text: "text-[var(--admin-warning)]", dot: "bg-[var(--admin-warning)]" },
|
||||
danger: { bg: "bg-[var(--admin-danger-light)]", text: "text-[var(--admin-danger)]", dot: "bg-[var(--admin-danger)]" },
|
||||
info: { bg: "bg-[var(--admin-border)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-info)]" },
|
||||
};
|
||||
|
||||
export default function AdminBadge({
|
||||
children,
|
||||
variant = "default",
|
||||
dot = false,
|
||||
className = ""
|
||||
}: AdminBadgeProps) {
|
||||
const { bg, text, dot: dotColor } = variantClasses[variant];
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] font-bold ${bg} ${text} ${className}`}>
|
||||
{dot && <span className={`h-1.5 w-1.5 rounded-full ${dotColor}`} />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Status badge with predefined statuses
|
||||
type AdminStatusBadgeProps = {
|
||||
status: "active" | "inactive" | "pending" | "draft" | "completed" | "cancelled";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const statusConfig: Record<string, { variant: BadgeVariant; dot: boolean; label: string }> = {
|
||||
active: { variant: "success", dot: true, label: "Active" },
|
||||
inactive: { variant: "default", dot: true, label: "Inactive" },
|
||||
pending: { variant: "warning", dot: true, label: "Pending" },
|
||||
draft: { variant: "default", dot: true, label: "Draft" },
|
||||
completed: { variant: "success", dot: true, label: "Completed" },
|
||||
cancelled: { variant: "danger", dot: true, label: "Cancelled" },
|
||||
};
|
||||
|
||||
export function AdminStatusBadge({ status, className = "" }: AdminStatusBadgeProps) {
|
||||
const config = statusConfig[status] || statusConfig.inactive;
|
||||
return (
|
||||
<AdminBadge variant={config.variant} dot={config.dot} className={className}>
|
||||
{config.label}
|
||||
</AdminBadge>
|
||||
);
|
||||
}
|
||||
|
||||
// Count badge (circular)
|
||||
type AdminCountBadgeProps = {
|
||||
count: number;
|
||||
variant?: BadgeVariant;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminCountBadge({ count, variant = "default", className = "" }: AdminCountBadgeProps) {
|
||||
const { bg, text } = variantClasses[variant];
|
||||
return (
|
||||
<span className={`inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5 text-xs font-bold ${bg} ${text} ${className}`}>
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, CSSProperties } from "react";
|
||||
|
||||
type AdminCardProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
noPadding?: boolean;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export default function AdminCard({ children, className = "", noPadding = false, style }: AdminCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl border bg-white shadow-[var(--admin-shadow-sm)] ${noPadding ? "" : "p-5"} ${className}`}
|
||||
style={{
|
||||
borderColor: 'var(--admin-border)',
|
||||
...style
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AdminCardHeaderProps = {
|
||||
title: string;
|
||||
action?: ReactNode;
|
||||
};
|
||||
|
||||
export function AdminCardHeader({ title, action }: AdminCardHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text-primary)]">{title}</h3>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AdminCardFooterProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function AdminCardFooter({ children }: AdminCardFooterProps) {
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t border-[var(--admin-border-light)]">{children}</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type AdminContainerProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AdminContainer({ children, className = "" }: AdminContainerProps) {
|
||||
return (
|
||||
<div className={`mx-auto max-w-6xl ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
type AdminDeleteConfirmProps = {
|
||||
title: string;
|
||||
itemName: string;
|
||||
description?: string;
|
||||
onConfirm: () => Promise<void> | void;
|
||||
onCancel?: () => void;
|
||||
deleteLabel?: string;
|
||||
};
|
||||
|
||||
export default function AdminDeleteConfirm({
|
||||
title,
|
||||
itemName,
|
||||
description,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
deleteLabel = "Delete",
|
||||
}: AdminDeleteConfirmProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setOpen(false);
|
||||
onCancel?.();
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<GlassModal title={title} onClose={handleCancel}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-[var(--admin-text-secondary)]">
|
||||
Are you sure you want to delete <span className="font-semibold text-[var(--admin-text-primary)]">"{itemName}"</span>?
|
||||
</p>
|
||||
{description && (
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">{description}</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--admin-border-light)]">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="rounded-xl border border-[var(--admin-border)] px-4 py-2 text-sm font-semibold text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={deleting}
|
||||
className="rounded-xl bg-[var(--admin-danger)] px-4 py-2 text-sm font-bold text-white hover:opacity-90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{deleting ? "Deleting..." : deleteLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook for managing delete confirmation state
|
||||
export function useDeleteConfirm() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const confirmDelete = (id: string, name: string) => setDeleteTarget({ id, name });
|
||||
const cancelDelete = () => setDeleteTarget(null);
|
||||
|
||||
return { deleteTarget, confirmDelete, cancelDelete };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type AdminEmptyStateProps = {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AdminEmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className = ""
|
||||
}: AdminEmptyStateProps) {
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center py-16 px-4 text-center ${className}`}>
|
||||
{icon ? (
|
||||
<div className="mb-4 text-[var(--admin-text-muted)]">{icon}</div>
|
||||
) : (
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
|
||||
<svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">{title}</h3>
|
||||
{description && <p className="mt-1 text-sm text-[var(--admin-text-muted)] max-w-sm">{description}</p>}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
type StatusOption = "all" | "active" | "inactive" | string;
|
||||
|
||||
type AdminFilterBarProps = {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
statusFilter?: StatusOption;
|
||||
onStatusChange?: (value: StatusOption) => void;
|
||||
statusOptions?: { value: StatusOption; label: string }[];
|
||||
count?: number;
|
||||
countLabel?: string;
|
||||
viewMode?: "table" | "cards";
|
||||
onViewModeChange?: (mode: "table" | "cards") => void;
|
||||
children?: React.ReactNode;
|
||||
onAddClick?: () => void;
|
||||
addLabel?: string;
|
||||
};
|
||||
|
||||
export default function AdminFilterBar({
|
||||
search,
|
||||
onSearchChange,
|
||||
searchPlaceholder = "Search...",
|
||||
statusFilter,
|
||||
onStatusChange,
|
||||
statusOptions,
|
||||
count,
|
||||
countLabel = "items",
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
children,
|
||||
onAddClick,
|
||||
addLabel = "Add",
|
||||
}: AdminFilterBarProps) {
|
||||
const defaultOptions = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "inactive", label: "Inactive" },
|
||||
];
|
||||
|
||||
const options = statusOptions ?? defaultOptions;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-4 shadow-[var(--admin-shadow-sm)]">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[12rem]">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-[var(--admin-text-muted)]" 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>
|
||||
<input
|
||||
type="search"
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="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 focus:border-[var(--admin-accent)] placeholder:text-[var(--admin-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Tabs */}
|
||||
{onStatusChange && (
|
||||
<div className="flex gap-1 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] p-1">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onStatusChange(opt.value)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition-all ${
|
||||
statusFilter === opt.value
|
||||
? "bg-white text-[var(--admin-accent-text)] shadow-[var(--admin-shadow-sm)] border border-[var(--admin-accent-light)]"
|
||||
: "text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-white/50"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Count */}
|
||||
{count !== undefined && (
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{count} {countLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* View Toggle */}
|
||||
{onViewModeChange && viewMode && (
|
||||
<div className="flex gap-1 rounded-xl border border-[var(--admin-border)] bg-white p-1">
|
||||
<button
|
||||
onClick={() => onViewModeChange("table")}
|
||||
className={`rounded-lg p-1.5 transition-all ${
|
||||
viewMode === "table"
|
||||
? "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)]"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onViewModeChange("cards")}
|
||||
className={`rounded-lg p-1.5 transition-all ${
|
||||
viewMode === "cards"
|
||||
? "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)]"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom children (e.g. extra filters) */}
|
||||
{children}
|
||||
|
||||
{/* Add Button */}
|
||||
{onAddClick && (
|
||||
<button
|
||||
onClick={onAddClick}
|
||||
className="ml-auto rounded-xl bg-[var(--admin-accent)] px-4 py-2 text-xs font-bold text-white hover:bg-[var(--admin-accent-hover)] transition-colors shadow-[var(--admin-shadow-sm)]"
|
||||
>
|
||||
+ {addLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
type AdminInputProps = {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helpText?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function AdminInput({
|
||||
label,
|
||||
error,
|
||||
helpText,
|
||||
required,
|
||||
className = "",
|
||||
children
|
||||
}: AdminInputProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<label className="block text-xs font-semibold text-[var(--admin-text-secondary)] mb-1.5">
|
||||
{label}
|
||||
{required && <span className="text-[var(--admin-danger)] ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{children}
|
||||
{helpText && !error && <p className="mt-1 text-[10px] text-[var(--admin-text-muted)]">{helpText}</p>}
|
||||
{error && <p className="mt-1 text-xs text-[var(--admin-danger)]">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Styled text input wrapper
|
||||
type AdminTextInputProps = {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminTextInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
disabled,
|
||||
className = ""
|
||||
}: 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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Styled textarea wrapper
|
||||
type AdminTextareaProps = {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminTextarea({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 3,
|
||||
disabled,
|
||||
className = ""
|
||||
}: 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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Styled select wrapper
|
||||
type AdminSelectProps = {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
options: { value: string; label: string }[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminSelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
disabled,
|
||||
className = ""
|
||||
}: 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}`}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// Styled checkbox wrapper
|
||||
type AdminCheckboxProps = {
|
||||
checked: boolean;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminCheckbox({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
disabled,
|
||||
className = ""
|
||||
}: AdminCheckboxProps) {
|
||||
return (
|
||||
<label className={`flex items-center gap-3 cursor-pointer ${disabled ? "opacity-50 cursor-not-allowed" : ""} ${className}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
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)]"
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--admin-text-secondary)]">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading spinner
|
||||
type AdminSpinnerProps = {
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminSpinner({ size = "md", className = "" }: AdminSpinnerProps) {
|
||||
const sizeClasses = {
|
||||
sm: "h-4 w-4 border-2",
|
||||
md: "h-6 w-6 border-2",
|
||||
lg: "h-8 w-8 border-3",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`animate-spin rounded-full border-[var(--admin-accent)] border-t-transparent ${sizeClasses[size]} ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading overlay
|
||||
type AdminLoadingOverlayProps = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function AdminLoadingOverlay({ message = "Loading..." }: AdminLoadingOverlayProps) {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 backdrop-blur-sm z-10">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<AdminSpinner size="lg" />
|
||||
<span className="text-sm text-[var(--admin-text-muted)]">{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type AdminLayoutProps = {
|
||||
children: ReactNode;
|
||||
maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "full";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const maxWidthClasses = {
|
||||
sm: "max-w-sm",
|
||||
md: "max-w-md",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-xl",
|
||||
"2xl": "max-w-6xl",
|
||||
full: "max-w-full",
|
||||
};
|
||||
|
||||
export default function AdminLayout({ children, maxWidth = "2xl", className = "" }: AdminLayoutProps) {
|
||||
return (
|
||||
<main className="min-h-screen admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className={`mx-auto px-6 py-10 ${maxWidthClasses[maxWidth]} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: string;
|
||||
};
|
||||
|
||||
export default function AdminModal({ title, subtitle, onClose, children, maxWidth = "max-w-md" }: Props) {
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
|
||||
>
|
||||
<div
|
||||
className={`relative w-full ${maxWidth} rounded-2xl`}
|
||||
style={{
|
||||
backgroundColor: "#ffffff",
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
{/* Accent bar */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 rounded-t-2xl overflow-hidden"
|
||||
style={{ background: "linear-gradient(90deg, var(--admin-accent) 0%, var(--admin-accent-hover) 100%)" }}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5" style={{ borderBottom: "1px solid var(--admin-border-light)" }}>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-[var(--admin-text-primary)]" style={{ letterSpacing: "-0.02em" }}>{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-sm text-[var(--admin-text-muted)]">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full transition-all hover:bg-[var(--admin-bg-subtle)]"
|
||||
style={{ backgroundColor: "rgba(60, 56, 37, 0.04)" }}
|
||||
>
|
||||
<svg className="h-5 w-5 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type AdminPageHeaderProps = {
|
||||
breadcrumb?: { label: string; href?: string }[];
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
type AdminPaginationProps = {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AdminPagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
className = ""
|
||||
}: AdminPaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center gap-2 ${className}`}>
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 0}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: totalPages }, (_, i) => i).map((page) => {
|
||||
// Show first, last, current, and adjacent pages
|
||||
const showPage =
|
||||
page === 0 ||
|
||||
page === totalPages - 1 ||
|
||||
Math.abs(page - currentPage) <= 1;
|
||||
|
||||
// Show ellipsis
|
||||
const showEllipsisBefore = page === 1 && currentPage > 2;
|
||||
const showEllipsisAfter = page === totalPages - 2 && currentPage < totalPages - 3;
|
||||
|
||||
if (!showPage && !showEllipsisBefore && !showEllipsisAfter) return null;
|
||||
|
||||
if (showEllipsisBefore || showEllipsisAfter) {
|
||||
return (
|
||||
<span key={`ellipsis-${page}`} className="px-2 text-[var(--admin-text-muted)]">
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={`flex h-8 min-w-[2rem] items-center justify-center rounded-lg text-sm font-medium transition-colors ${
|
||||
page === currentPage
|
||||
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)]"
|
||||
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
|
||||
}`}
|
||||
>
|
||||
{page + 1}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages - 1}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple previous/next pagination
|
||||
type AdminSimplePaginationProps = {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminSimplePagination({
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
className = ""
|
||||
}: AdminSimplePaginationProps) {
|
||||
return (
|
||||
<div className={`flex items-center justify-between ${className}`}>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
Page {page + 1} of {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page === 0}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
type StatItem = {
|
||||
label: string;
|
||||
value: number;
|
||||
variant?: "default" | "success" | "warning" | "info";
|
||||
};
|
||||
|
||||
type AdminStatsBarProps = {
|
||||
stats: StatItem[];
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
default: "bg-[var(--admin-border-light)] border-[var(--admin-border)] text-[var(--admin-text-secondary)]",
|
||||
success: "bg-[var(--admin-accent-light)] border-[var(--admin-accent)] text-[var(--admin-accent-text)]",
|
||||
warning: "bg-[var(--admin-warning-light)] border-[var(--admin-warning)] text-[var(--admin-text-primary)]",
|
||||
info: "bg-[var(--admin-border)] border-[var(--admin-text-muted)] text-[var(--admin-text-secondary)]",
|
||||
};
|
||||
|
||||
export default function AdminStatsBar({ stats }: AdminStatsBarProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
{stats.map((stat, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--admin-text-muted)]">{stat.label}</span>
|
||||
<span className={`rounded-full border px-2.5 py-0.5 text-sm font-bold ${variantClasses[stat.variant ?? "default"]}`}>
|
||||
{stat.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type Column<T> = {
|
||||
key: string;
|
||||
header: string;
|
||||
render?: (item: T) => ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type AdminTableProps<T> = {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
keyExtractor: (item: T) => string;
|
||||
onRowClick?: (item: T) => void;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AdminTable<T extends Record<string, unknown>>({
|
||||
data,
|
||||
columns,
|
||||
keyExtractor,
|
||||
onRowClick,
|
||||
emptyMessage = "No items found",
|
||||
className = "",
|
||||
}: AdminTableProps<T>) {
|
||||
return (
|
||||
<div className={`overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-[var(--admin-shadow-sm)] ${className}`}>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
|
||||
{columns.map((col) => (
|
||||
<th key={col.key} className={`px-5 py-3.5 text-[10px] font-bold uppercase tracking-widest text-[var(--admin-text-muted)] ${col.className ?? ""}`}>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border-light)]">
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-5 py-16 text-center text-sm text-[var(--admin-text-muted)]">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((item) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
className={`hover:bg-[var(--admin-bg-subtle)]/40 transition-colors ${onRowClick ? "cursor-pointer" : ""}`}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={`px-5 py-3.5 ${col.className ?? ""}`}>
|
||||
{col.render ? col.render(item) : String(item[col.key] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper for status badges
|
||||
export function TableStatusBadge({ status }: { status: "active" | "inactive" | "pending" | "completed" }) {
|
||||
const config = {
|
||||
active: { bg: "bg-[var(--admin-accent-light)]", text: "text-[var(--admin-accent-text)]", dot: "bg-[var(--admin-accent)]" },
|
||||
inactive: { bg: "bg-[var(--admin-border-light)]", text: "text-[var(--admin-text-muted)]", dot: "bg-[var(--admin-text-muted)]" },
|
||||
pending: { bg: "bg-[var(--admin-warning-light)]", text: "text-[var(--admin-warning)]", dot: "bg-[var(--admin-warning)]" },
|
||||
completed: { bg: "bg-[var(--admin-border)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-info)]" },
|
||||
};
|
||||
const { bg, text, dot } = config[status];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] font-bold ${bg} ${text}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${dot}`} />
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Core components
|
||||
export { default as AdminLayout } from "./AdminLayout";
|
||||
export { default as AdminContainer } from "./AdminContainer";
|
||||
export { default as AdminPageHeader } from "./AdminPageHeader";
|
||||
export { default as AdminCard, AdminCardHeader, AdminCardFooter } from "./AdminCard";
|
||||
export { default as AdminStatsBar } from "./AdminStatsBar";
|
||||
export { default as AdminFilterBar } from "./AdminFilterBar";
|
||||
export { default as AdminTable, TableStatusBadge } from "./AdminTable";
|
||||
export { default as AdminEmptyState } from "./AdminEmptyState";
|
||||
export { default as AdminDeleteConfirm, useDeleteConfirm } from "./AdminDeleteConfirm";
|
||||
export { default as AdminActionMenu, AdminActionButton } from "./AdminActionMenu";
|
||||
export { default as AdminPagination, AdminSimplePagination } from "./AdminPagination";
|
||||
export { default as AdminBadge, AdminStatusBadge, AdminCountBadge } from "./AdminBadge";
|
||||
|
||||
// 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
|
||||
export { default as GlassModal } from "@/components/admin/GlassModal";
|
||||
Reference in New Issue
Block a user