pounce/frontend/src/components/PremiumTable.tsx
yves.gugger ff05d5b2b5 feat: Add reusable filter/search components for consistency
NEW COMPONENTS in PremiumTable.tsx:

1. SearchInput
   - Consistent search box styling across all pages
   - Left-aligned search icon
   - Clear button (X) when text entered
   - Props: value, onChange, placeholder, onClear

2. TabBar
   - Consistent tab styling with counts
   - Support for accent/warning/default colors
   - Optional icons
   - Wraps on mobile
   - Props: tabs[], activeTab, onChange

3. FilterBar
   - Simple flex container for filter rows
   - Responsive: stacks on mobile, row on desktop
   - Props: children

4. SelectDropdown
   - Consistent select styling
   - Custom chevron icon
   - Props: value, onChange, options[]

5. ActionButton
   - Consistent button styling
   - Variants: primary (accent), secondary (outlined), ghost
   - Sizes: default, small
   - Optional icon
   - Props: children, onClick, disabled, variant, size, icon

These components ensure visual consistency across all
Command Center pages for search, filtering, and actions.
2025-12-10 16:30:38 +01:00

633 lines
21 KiB
TypeScript
Executable File

'use client'
import { ReactNode } from 'react'
import clsx from 'clsx'
import { ChevronUp, ChevronDown, ChevronsUpDown, Loader2 } from 'lucide-react'
// ============================================================================
// PREMIUM TABLE - Elegant, consistent styling for all tables
// ============================================================================
interface Column<T> {
key: string
header: string | ReactNode
render?: (item: T, index: number) => ReactNode
className?: string
headerClassName?: string
hideOnMobile?: boolean
hideOnTablet?: boolean
sortable?: boolean
align?: 'left' | 'center' | 'right'
width?: string
}
interface PremiumTableProps<T> {
data: T[]
columns: Column<T>[]
keyExtractor: (item: T) => string | number
onRowClick?: (item: T) => void
emptyState?: ReactNode
emptyIcon?: ReactNode
emptyTitle?: string
emptyDescription?: string
loading?: boolean
sortBy?: string
sortDirection?: 'asc' | 'desc'
onSort?: (key: string) => void
compact?: boolean
striped?: boolean
hoverable?: boolean
}
export function PremiumTable<T>({
data,
columns,
keyExtractor,
onRowClick,
emptyState,
emptyIcon,
emptyTitle = 'No data',
emptyDescription,
loading,
sortBy,
sortDirection = 'asc',
onSort,
compact = false,
striped = false,
hoverable = true,
}: PremiumTableProps<T>) {
const cellPadding = compact ? 'px-4 py-3' : 'px-6 py-4'
const headerPadding = compact ? 'px-4 py-3' : 'px-6 py-4'
if (loading) {
return (
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm">
<div className="divide-y divide-border/20">
{[...Array(5)].map((_, i) => (
<div key={i} className={clsx("flex gap-4 items-center", cellPadding)} style={{ animationDelay: `${i * 50}ms` }}>
<div className="h-5 w-32 bg-foreground/5 rounded-lg animate-pulse" />
<div className="h-5 w-24 bg-foreground/5 rounded-lg animate-pulse hidden sm:block" />
<div className="h-5 w-20 bg-foreground/5 rounded-lg animate-pulse ml-auto" />
</div>
))}
</div>
</div>
)
}
if (data.length === 0) {
return (
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm">
<div className="px-8 py-16 text-center">
{emptyState || (
<>
{emptyIcon && <div className="flex justify-center mb-4">{emptyIcon}</div>}
<p className="text-foreground-muted font-medium">{emptyTitle}</p>
{emptyDescription && <p className="text-sm text-foreground-subtle mt-1">{emptyDescription}</p>}
</>
)}
</div>
</div>
)
}
return (
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-b from-background-secondary/40 to-background-secondary/20 backdrop-blur-sm shadow-[0_4px_24px_-4px_rgba(0,0,0,0.08)]">
<div className="overflow-x-auto">
<table className="w-full table-fixed">
<thead>
<tr className="border-b border-border/40 bg-background-secondary/30">
{columns.map((col) => (
<th
key={col.key}
className={clsx(
headerPadding,
"text-[11px] font-semibold text-foreground-subtle/70 uppercase tracking-wider whitespace-nowrap",
col.hideOnMobile && "hidden md:table-cell",
col.hideOnTablet && "hidden lg:table-cell",
col.align === 'right' && "text-right",
col.align === 'center' && "text-center",
!col.align && "text-left",
col.headerClassName
)}
style={col.width ? { width: col.width, minWidth: col.width } : undefined}
>
{col.sortable && onSort ? (
<button
onClick={() => onSort(col.key)}
className={clsx(
"inline-flex items-center gap-1.5 hover:text-foreground transition-colors group",
col.align === 'right' && "justify-end w-full",
col.align === 'center' && "justify-center w-full"
)}
>
{col.header}
<SortIndicator
active={sortBy === col.key}
direction={sortBy === col.key ? sortDirection : undefined}
/>
</button>
) : (
col.header
)}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/20">
{data.map((item, index) => {
const key = keyExtractor(item)
return (
<tr
key={key}
onClick={() => onRowClick?.(item)}
className={clsx(
"group transition-all duration-200",
onRowClick && "cursor-pointer",
hoverable && "hover:bg-foreground/[0.02]",
striped && index % 2 === 1 && "bg-foreground/[0.01]"
)}
>
{columns.map((col) => (
<td
key={col.key}
className={clsx(
cellPadding,
"text-sm align-middle",
col.hideOnMobile && "hidden md:table-cell",
col.hideOnTablet && "hidden lg:table-cell",
col.align === 'right' && "text-right",
col.align === 'center' && "text-center",
!col.align && "text-left",
col.className
)}
>
{col.render
? col.render(item, index)
: (item as Record<string, unknown>)[col.key] as ReactNode
}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
// ============================================================================
// SORT INDICATOR
// ============================================================================
function SortIndicator({ active, direction }: { active: boolean; direction?: 'asc' | 'desc' }) {
if (!active) {
return <ChevronsUpDown className="w-3.5 h-3.5 text-foreground-subtle/50 group-hover:text-foreground-muted transition-colors" />
}
return direction === 'asc'
? <ChevronUp className="w-3.5 h-3.5 text-accent" />
: <ChevronDown className="w-3.5 h-3.5 text-accent" />
}
// ============================================================================
// STATUS BADGE
// ============================================================================
type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'accent' | 'info'
export function Badge({
children,
variant = 'default',
size = 'sm',
dot = false,
pulse = false,
}: {
children: ReactNode
variant?: BadgeVariant
size?: 'xs' | 'sm' | 'md'
dot?: boolean
pulse?: boolean
}) {
const variants: Record<BadgeVariant, string> = {
default: "bg-foreground/5 text-foreground-muted border-border/50",
success: "bg-accent/10 text-accent border-accent/20",
warning: "bg-amber-500/10 text-amber-400 border-amber-500/20",
error: "bg-red-500/10 text-red-400 border-red-500/20",
accent: "bg-accent/10 text-accent border-accent/20",
info: "bg-blue-500/10 text-blue-400 border-blue-500/20",
}
const sizes = {
xs: "text-[10px] px-1.5 py-0.5",
sm: "text-xs px-2 py-0.5",
md: "text-xs px-2.5 py-1",
}
return (
<span className={clsx(
"inline-flex items-center gap-1.5 font-medium rounded-md border",
variants[variant],
sizes[size]
)}>
{dot && (
<span className="relative flex h-2 w-2">
{pulse && (
<span className={clsx(
"animate-ping absolute inline-flex h-full w-full rounded-full opacity-75",
variant === 'success' || variant === 'accent' ? "bg-accent" :
variant === 'warning' ? "bg-amber-400" :
variant === 'error' ? "bg-red-400" : "bg-foreground"
)} />
)}
<span className={clsx(
"relative inline-flex rounded-full h-2 w-2",
variant === 'success' || variant === 'accent' ? "bg-accent" :
variant === 'warning' ? "bg-amber-400" :
variant === 'error' ? "bg-red-400" :
variant === 'info' ? "bg-blue-400" : "bg-foreground-muted"
)} />
</span>
)}
{children}
</span>
)
}
// ============================================================================
// TABLE ACTION BUTTON
// ============================================================================
export function TableActionButton({
icon: Icon,
onClick,
variant = 'default',
title,
disabled,
loading,
}: {
icon: React.ComponentType<{ className?: string }>
onClick?: () => void
variant?: 'default' | 'danger' | 'accent'
title?: string
disabled?: boolean
loading?: boolean
}) {
const variants = {
default: "text-foreground-muted hover:text-foreground hover:bg-foreground/5 border-transparent",
danger: "text-foreground-muted hover:text-red-400 hover:bg-red-500/10 border-transparent hover:border-red-500/20",
accent: "text-accent bg-accent/10 border-accent/20 hover:bg-accent/20",
}
return (
<button
onClick={(e) => {
e.stopPropagation()
onClick?.()
}}
disabled={disabled || loading}
title={title}
className={clsx(
"p-2 rounded-lg border transition-all duration-200",
"disabled:opacity-30 disabled:cursor-not-allowed",
variants[variant]
)}
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Icon className="w-4 h-4" />
)}
</button>
)
}
// ============================================================================
// PLATFORM BADGE (for auctions)
// ============================================================================
export function PlatformBadge({ platform }: { platform: string }) {
const colors: Record<string, string> = {
'GoDaddy': 'text-blue-400 bg-blue-400/10 border-blue-400/20',
'Sedo': 'text-orange-400 bg-orange-400/10 border-orange-400/20',
'NameJet': 'text-purple-400 bg-purple-400/10 border-purple-400/20',
'DropCatch': 'text-teal-400 bg-teal-400/10 border-teal-400/20',
'ExpiredDomains': 'text-pink-400 bg-pink-400/10 border-pink-400/20',
}
return (
<span className={clsx(
"inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-md border",
colors[platform] || "text-foreground-muted bg-foreground/5 border-border/50"
)}>
{platform}
</span>
)
}
// ============================================================================
// STAT CARD (for page headers)
// ============================================================================
export function StatCard({
title,
value,
subtitle,
icon: Icon,
accent = false,
trend,
}: {
title: string
value: string | number
subtitle?: string
icon?: React.ComponentType<{ className?: string }>
accent?: boolean
trend?: { value: number; label?: string }
}) {
return (
<div className={clsx(
"relative p-5 rounded-2xl border overflow-hidden transition-all duration-300",
accent
? "bg-gradient-to-br from-accent/15 to-accent/5 border-accent/30"
: "bg-gradient-to-br from-background-secondary/60 to-background-secondary/30 border-border/50 hover:border-accent/30"
)}>
{accent && <div className="absolute top-0 right-0 w-20 h-20 bg-accent/10 rounded-full blur-2xl" />}
<div className="relative">
{Icon && (
<div className={clsx(
"w-10 h-10 rounded-xl flex items-center justify-center mb-3",
accent ? "bg-accent/20 border border-accent/30" : "bg-foreground/5 border border-border/30"
)}>
<Icon className={clsx("w-5 h-5", accent ? "text-accent" : "text-foreground-muted")} />
</div>
)}
<p className="text-[10px] text-foreground-subtle uppercase tracking-wider mb-1">{title}</p>
<p className={clsx("text-2xl font-semibold", accent ? "text-accent" : "text-foreground")}>
{typeof value === 'number' ? value.toLocaleString() : value}
</p>
{subtitle && <p className="text-xs text-foreground-subtle mt-0.5">{subtitle}</p>}
{trend && (
<div className={clsx(
"inline-flex items-center gap-1 mt-2 text-xs font-medium px-2 py-0.5 rounded",
trend.value > 0 ? "text-accent bg-accent/10" : trend.value < 0 ? "text-red-400 bg-red-400/10" : "text-foreground-muted bg-foreground/5"
)}>
{trend.value > 0 ? '+' : ''}{trend.value}%
{trend.label && <span className="text-foreground-subtle">{trend.label}</span>}
</div>
)}
</div>
</div>
)
}
// ============================================================================
// PAGE CONTAINER (consistent max-width)
// ============================================================================
export function PageContainer({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={clsx("space-y-6", className)}>
{children}
</div>
)
}
// ============================================================================
// SECTION HEADER
// ============================================================================
export function SectionHeader({
title,
subtitle,
icon: Icon,
action,
compact = false,
}: {
title: string
subtitle?: string
icon?: React.ComponentType<{ className?: string }>
action?: ReactNode
compact?: boolean
}) {
return (
<div className={clsx("flex items-center justify-between", !compact && "mb-6")}>
<div className="flex items-center gap-3">
{Icon && (
<div className={clsx(
"bg-accent/10 border border-accent/20 rounded-xl flex items-center justify-center",
compact ? "w-9 h-9" : "w-10 h-10"
)}>
<Icon className={clsx(compact ? "w-4 h-4" : "w-5 h-5", "text-accent")} />
</div>
)}
<div>
<h2 className={clsx(compact ? "text-base" : "text-lg", "font-semibold text-foreground")}>{title}</h2>
{subtitle && <p className="text-sm text-foreground-muted">{subtitle}</p>}
</div>
</div>
{action}
</div>
)
}
// ============================================================================
// SEARCH INPUT (consistent search styling)
// ============================================================================
import { Search, X } from 'lucide-react'
export function SearchInput({
value,
onChange,
placeholder = 'Search...',
onClear,
className,
}: {
value: string
onChange: (value: string) => void
placeholder?: string
onClear?: () => void
className?: string
}) {
return (
<div className={clsx("relative", className)}>
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted" />
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full h-10 pl-10 pr-9 bg-background-secondary/50 border border-border/40 rounded-xl
text-sm text-foreground placeholder:text-foreground-subtle
focus:outline-none focus:border-accent/50 focus:bg-background-secondary/80 transition-all"
/>
{value && (onClear || onChange) && (
<button
onClick={() => onClear ? onClear() : onChange('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-foreground-subtle hover:text-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
)
}
// ============================================================================
// TAB BAR (consistent tab styling)
// ============================================================================
interface TabItem {
id: string
label: string
icon?: React.ComponentType<{ className?: string }>
count?: number
color?: 'default' | 'accent' | 'warning'
}
export function TabBar({
tabs,
activeTab,
onChange,
className,
}: {
tabs: TabItem[]
activeTab: string
onChange: (id: string) => void
className?: string
}) {
return (
<div className={clsx("flex flex-wrap items-center gap-1.5 p-1.5 bg-background-secondary/30 border border-border/30 rounded-xl w-fit", className)}>
{tabs.map((tab) => {
const isActive = activeTab === tab.id
const Icon = tab.icon
return (
<button
key={tab.id}
onClick={() => onChange(tab.id)}
className={clsx(
"flex items-center gap-2 px-3.5 py-2 text-sm font-medium rounded-lg transition-all",
isActive
? tab.color === 'warning'
? "bg-amber-500 text-background shadow-md"
: tab.color === 'accent'
? "bg-accent text-background shadow-md shadow-accent/20"
: "bg-foreground/10 text-foreground"
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
)}
>
{Icon && <Icon className="w-4 h-4" />}
<span className="hidden sm:inline">{tab.label}</span>
{tab.count !== undefined && (
<span className={clsx(
"text-xs px-1.5 py-0.5 rounded-md tabular-nums",
isActive ? "bg-background/20" : "bg-foreground/10"
)}>
{tab.count}
</span>
)}
</button>
)
})}
</div>
)
}
// ============================================================================
// FILTER BAR (row of filters: search + select + buttons)
// ============================================================================
export function FilterBar({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<div className={clsx("flex flex-col sm:flex-row gap-3 sm:items-center", className)}>
{children}
</div>
)
}
// ============================================================================
// SELECT DROPDOWN (consistent select styling)
// ============================================================================
import { ChevronDown } from 'lucide-react'
export function SelectDropdown({
value,
onChange,
options,
className,
}: {
value: string
onChange: (value: string) => void
options: { value: string; label: string }[]
className?: string
}) {
return (
<div className={clsx("relative", className)}>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-10 pl-3.5 pr-9 bg-background-secondary/50 border border-border/40 rounded-xl
text-sm text-foreground appearance-none cursor-pointer
focus:outline-none focus:border-accent/50 focus:bg-background-secondary/80 transition-all"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-muted pointer-events-none" />
</div>
)
}
// ============================================================================
// ACTION BUTTON (consistent button styling)
// ============================================================================
export function ActionButton({
children,
onClick,
disabled,
variant = 'primary',
size = 'default',
icon: Icon,
className,
}: {
children: ReactNode
onClick?: () => void
disabled?: boolean
variant?: 'primary' | 'secondary' | 'ghost'
size?: 'small' | 'default'
icon?: React.ComponentType<{ className?: string }>
className?: string
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className={clsx(
"flex items-center justify-center gap-2 font-medium rounded-xl transition-all",
"disabled:opacity-50 disabled:cursor-not-allowed",
size === 'small' ? "h-8 px-3 text-xs" : "h-10 px-4 text-sm",
variant === 'primary' && "bg-accent text-background hover:bg-accent-hover shadow-lg shadow-accent/20",
variant === 'secondary' && "bg-foreground/10 text-foreground hover:bg-foreground/15 border border-border/40",
variant === 'ghost' && "text-foreground-muted hover:text-foreground hover:bg-foreground/5",
className
)}
>
{Icon && <Icon className={size === 'small' ? "w-3.5 h-3.5" : "w-4 h-4"} />}
{children}
</button>
)
}