298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
import React from 'react'
|
|
import { Loader2 } from 'lucide-react'
|
|
|
|
// ─── Button ───────────────────────────────────────────────────────────────────
|
|
|
|
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'
|
|
type ButtonSize = 'sm' | 'md' | 'lg'
|
|
|
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
variant?: ButtonVariant
|
|
size?: ButtonSize
|
|
loading?: boolean
|
|
icon?: React.ReactNode
|
|
}
|
|
|
|
const variantClasses: Record<ButtonVariant, string> = {
|
|
primary:
|
|
'bg-accent-violet hover:bg-accent-violet-light text-white shadow-glow hover:shadow-lg transition-all',
|
|
secondary:
|
|
'bg-bg-elevated border border-bg-border text-text-primary hover:border-accent-violet hover:text-accent-violet-light transition-all',
|
|
ghost: 'text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-all',
|
|
danger: 'bg-danger/20 border border-danger/50 text-danger hover:bg-danger/30 transition-all',
|
|
}
|
|
|
|
const sizeClasses: Record<ButtonSize, string> = {
|
|
sm: 'px-3 py-1.5 text-sm',
|
|
md: 'px-4 py-2 text-sm',
|
|
lg: 'px-6 py-3 text-base',
|
|
}
|
|
|
|
export function Button({
|
|
variant = 'primary',
|
|
size = 'md',
|
|
loading,
|
|
icon,
|
|
children,
|
|
disabled,
|
|
className = '',
|
|
...props
|
|
}: ButtonProps) {
|
|
return (
|
|
<button
|
|
{...props}
|
|
disabled={disabled || loading}
|
|
className={`
|
|
inline-flex items-center gap-2 rounded-md font-medium
|
|
disabled:opacity-50 disabled:cursor-not-allowed
|
|
${variantClasses[variant]} ${sizeClasses[size]} ${className}
|
|
`}
|
|
>
|
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon}
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
// ─── Input ────────────────────────────────────────────────────────────────────
|
|
|
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
label?: string
|
|
error?: string
|
|
hint?: string
|
|
}
|
|
|
|
export function Input({ label, error, hint, className = '', ...props }: InputProps) {
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
{label && (
|
|
<label className="text-xs font-medium text-text-secondary uppercase tracking-wider">
|
|
{label}
|
|
</label>
|
|
)}
|
|
<input
|
|
{...props}
|
|
className={`
|
|
bg-bg-elevated border rounded-md px-3 py-2 text-sm text-text-primary
|
|
placeholder:text-text-muted
|
|
focus:outline-none focus:ring-2 focus:ring-accent-violet focus:border-transparent
|
|
${error ? 'border-danger' : 'border-bg-border'}
|
|
${className}
|
|
`}
|
|
/>
|
|
{error && <p className="text-xs text-danger">{error}</p>}
|
|
{hint && !error && <p className="text-xs text-text-muted">{hint}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Textarea ─────────────────────────────────────────────────────────────────
|
|
|
|
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
label?: string
|
|
error?: string
|
|
hint?: string
|
|
}
|
|
|
|
export function Textarea({ label, error, hint, className = '', ...props }: TextareaProps) {
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
{label && (
|
|
<label className="text-xs font-medium text-text-secondary uppercase tracking-wider">
|
|
{label}
|
|
</label>
|
|
)}
|
|
<textarea
|
|
{...props}
|
|
className={`
|
|
bg-bg-elevated border rounded-md px-3 py-2 text-sm text-text-primary
|
|
placeholder:text-text-muted resize-none
|
|
focus:outline-none focus:ring-2 focus:ring-accent-violet focus:border-transparent
|
|
${error ? 'border-danger' : 'border-bg-border'}
|
|
${className}
|
|
`}
|
|
/>
|
|
{error && <p className="text-xs text-danger">{error}</p>}
|
|
{hint && !error && <p className="text-xs text-text-muted">{hint}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Select ───────────────────────────────────────────────────────────────────
|
|
|
|
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
|
label?: string
|
|
error?: string
|
|
}
|
|
|
|
export function Select({ label, error, className = '', children, ...props }: SelectProps) {
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
{label && (
|
|
<label className="text-xs font-medium text-text-secondary uppercase tracking-wider">
|
|
{label}
|
|
</label>
|
|
)}
|
|
<select
|
|
{...props}
|
|
className={`
|
|
bg-bg-elevated border rounded-md px-3 py-2 text-sm text-text-primary
|
|
focus:outline-none focus:ring-2 focus:ring-accent-violet focus:border-transparent
|
|
${error ? 'border-danger' : 'border-bg-border'}
|
|
${className}
|
|
`}
|
|
>
|
|
{children}
|
|
</select>
|
|
{error && <p className="text-xs text-danger">{error}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Toggle ───────────────────────────────────────────────────────────────────
|
|
|
|
interface ToggleProps {
|
|
checked: boolean
|
|
onChange: (checked: boolean) => void
|
|
label?: string
|
|
description?: string
|
|
disabled?: boolean
|
|
}
|
|
|
|
export function Toggle({ checked, onChange, label, description, disabled }: ToggleProps) {
|
|
return (
|
|
<label className="flex items-start gap-3 cursor-pointer select-none group">
|
|
<div className="relative mt-0.5 flex-shrink-0">
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={(e) => onChange(e.target.checked)}
|
|
disabled={disabled}
|
|
className="sr-only"
|
|
/>
|
|
<div
|
|
className={`
|
|
w-10 h-6 rounded-full transition-colors duration-200
|
|
${checked ? 'bg-accent-violet' : 'bg-bg-border'}
|
|
${disabled ? 'opacity-50' : ''}
|
|
`}
|
|
/>
|
|
<div
|
|
className={`
|
|
absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform duration-200
|
|
${checked ? 'translate-x-4' : 'translate-x-0'}
|
|
`}
|
|
/>
|
|
</div>
|
|
{(label || description) && (
|
|
<div className="flex flex-col">
|
|
{label && <span className="text-sm font-medium text-text-primary">{label}</span>}
|
|
{description && <span className="text-xs text-text-muted">{description}</span>}
|
|
</div>
|
|
)}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
// ─── Badge ────────────────────────────────────────────────────────────────────
|
|
|
|
type BadgeVariant = 'owned' | 'unowned' | 'default' | 'mode' | 'slot'
|
|
|
|
interface BadgeProps {
|
|
variant?: BadgeVariant
|
|
children: React.ReactNode
|
|
className?: string
|
|
}
|
|
|
|
const badgeVariants: Record<BadgeVariant, string> = {
|
|
owned: 'bg-owned/20 text-owned border border-owned/30',
|
|
unowned: 'bg-unowned/20 text-unowned border border-unowned/30',
|
|
default: 'bg-bg-elevated text-text-secondary border border-bg-border',
|
|
mode: 'bg-accent-violet/20 text-accent-violet-light border border-accent-violet/30',
|
|
slot: 'bg-bg-surface text-text-secondary border border-bg-border',
|
|
}
|
|
|
|
export function Badge({ variant = 'default', children, className = '' }: BadgeProps) {
|
|
return (
|
|
<span
|
|
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${badgeVariants[variant]} ${className}`}
|
|
>
|
|
{children}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ─── Card (panel) ─────────────────────────────────────────────────────────────
|
|
|
|
interface PanelProps {
|
|
children: React.ReactNode
|
|
className?: string
|
|
glow?: boolean
|
|
}
|
|
|
|
export function Panel({ children, className = '', glow }: PanelProps) {
|
|
return (
|
|
<div
|
|
className={`
|
|
bg-bg-surface rounded-lg border border-bg-border
|
|
${glow ? 'shadow-glow' : 'shadow-card'}
|
|
${className}
|
|
`}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Spinner ──────────────────────────────────────────────────────────────────
|
|
|
|
export function Spinner({ size = 'md', className = '' }: { size?: 'sm' | 'md' | 'lg'; className?: string }) {
|
|
const s = { sm: 'w-4 h-4', md: 'w-6 h-6', lg: 'w-10 h-10' }[size]
|
|
return <Loader2 className={`${s} animate-spin text-accent-violet ${className}`} />
|
|
}
|
|
|
|
// ─── Empty state ──────────────────────────────────────────────────────────────
|
|
|
|
export function EmptyState({
|
|
icon,
|
|
title,
|
|
description,
|
|
action,
|
|
}: {
|
|
icon?: React.ReactNode
|
|
title: string
|
|
description?: string
|
|
action?: React.ReactNode
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-16 gap-4 text-center">
|
|
{icon && <div className="text-text-muted w-12 h-12">{icon}</div>}
|
|
<div className="flex flex-col gap-1">
|
|
<p className="text-text-primary font-medium">{title}</p>
|
|
{description && <p className="text-text-muted text-sm max-w-sm">{description}</p>}
|
|
</div>
|
|
{action}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Error message ────────────────────────────────────────────────────────────
|
|
|
|
export function ErrorMessage({ message }: { message: string }) {
|
|
return (
|
|
<div className="bg-danger/10 border border-danger/30 rounded-md p-3 text-sm text-danger">
|
|
{message}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Skeleton loader ──────────────────────────────────────────────────────────
|
|
|
|
export function Skeleton({ className = '' }: { className?: string }) {
|
|
return (
|
|
<div
|
|
className={`rounded bg-gradient-to-r from-bg-elevated via-bg-border to-bg-elevated bg-[length:200%_100%] animate-shimmer ${className}`}
|
|
/>
|
|
)
|
|
}
|