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:
2026-06-02 02:21:11 +00:00
parent 809e0061ca
commit 15e939ad7e
116 changed files with 14991 additions and 5326 deletions
@@ -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)]">&quot;{itemName}&quot;</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";