Visual redesign: design system, refined ops console aesthetic
- Full component library (Button, Input, Label, Select, Modal, Table, Badge, Card, etc.) - Tailwind design tokens: IBM Plex Sans + JetBrains Mono, teal accent, semantic status colors - NavBar with logo, responsive hamburger menu, real logout - All pages redesigned: Login, Dashboard (KPI cards), Machines, SyncPairs, JobHistory, JobDetail, SSHKeys, Settings - Fixed: hover:bg-gray-750 dead class, window.location.href navigation bug - Replaced alert()/confirm() with sonner toasts and accessible modals - Added ErrorBoundary, skip link, accessible modal dialogs (Radix) - Icons: lucide-react throughout, copy/download buttons - 1.0.5 → 1.0.6
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import * as React from 'react'
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
||||
import { Button } from './ui/Button'
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode
|
||||
fallback?: React.ReactNode
|
||||
}
|
||||
|
||||
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo)
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4 p-8 text-center">
|
||||
<div className="rounded-full bg-rose-500/10 p-4 text-rose-400">
|
||||
<AlertTriangle className="h-8 w-8" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-fg">Something went wrong</h2>
|
||||
<p className="text-sm text-fg-muted max-w-md">
|
||||
{this.state.error?.message ?? 'An unexpected error occurred'}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={this.handleReload} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Reload page
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export { ErrorBoundary }
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { badgeVariants, type BadgeVariant } from '@/lib/status'
|
||||
import { statusLabel } from '@/lib/status'
|
||||
|
||||
interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {
|
||||
label?: string
|
||||
}
|
||||
|
||||
const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
({ className, variant, label, children, ...props }, ref) => {
|
||||
const displayLabel = label ?? (typeof variant === 'string' ? statusLabel(variant) : children)
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
{displayLabel}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
)
|
||||
Badge.displayName = 'Badge'
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export type { BadgeVariant }
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 rounded-card font-medium transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-canvas disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary:
|
||||
'bg-accent text-accent-foreground hover:bg-accent-hover shadow-sm hover:shadow-glow',
|
||||
secondary:
|
||||
'bg-surface-raised text-fg border border-border hover:bg-surface-hover hover:border-border/80',
|
||||
ghost:
|
||||
'text-fg-muted hover:text-fg hover:bg-surface-raised',
|
||||
danger:
|
||||
'bg-rose-500/15 text-rose-400 border border-rose-500/30 hover:bg-rose-500/25 hover:border-rose-500/50 hover:shadow-glow-error',
|
||||
'danger-solid':
|
||||
'bg-rose-500 text-white hover:bg-rose-600 shadow-sm',
|
||||
link:
|
||||
'text-accent underline-offset-4 hover:underline hover:text-accent-hover',
|
||||
},
|
||||
size: {
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
md: 'h-9 px-4 text-sm',
|
||||
lg: 'h-11 px-6 text-base',
|
||||
icon: 'h-9 w-9',
|
||||
'icon-sm': 'h-8 w-8',
|
||||
'icon-lg': 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
loading?: boolean
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, loading, disabled, asChild = false, children, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
disabled={asChild ? undefined : (disabled || loading)}
|
||||
{...props}
|
||||
>
|
||||
{loading && !asChild && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{children}
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-card border border-border bg-surface shadow-card',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col gap-1 p-5', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold text-fg', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-xs text-fg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardBody = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
|
||||
))
|
||||
CardBody.displayName = 'CardBody'
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center gap-3 p-5 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardBody, CardFooter }
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { Button } from './Button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CopyButtonProps
|
||||
extends Omit<React.ComponentProps<typeof Button>, 'variant' | 'size' | 'children'> {
|
||||
text: string
|
||||
displayText?: string
|
||||
successDuration?: number
|
||||
}
|
||||
|
||||
const CopyButton = React.forwardRef<HTMLButtonElement, CopyButtonProps>(
|
||||
(
|
||||
{ className, text, displayText, successDuration = 2000, ...props },
|
||||
ref
|
||||
) => {
|
||||
const [copied, setCopied] = React.useState(false)
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), successDuration)
|
||||
} catch {
|
||||
}
|
||||
}, [text, successDuration])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className={cn('gap-1.5 text-fg-muted hover:text-fg', className)}
|
||||
{...props}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5 text-emerald-400" />
|
||||
<span className="text-emerald-400">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
<span>{displayText ?? 'Copy'}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
)
|
||||
CopyButton.displayName = 'CopyButton'
|
||||
|
||||
export { CopyButton }
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
icon?: React.ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
}
|
||||
|
||||
const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(
|
||||
({ className, icon, title, description, action, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3 py-12 text-center',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && (
|
||||
<div className="rounded-full bg-surface-raised p-3 text-fg-subtle">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-fg">{title}</p>
|
||||
{description && (
|
||||
<p className="text-xs text-fg-muted max-w-xs">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="mt-1">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
EmptyState.displayName = 'EmptyState'
|
||||
|
||||
export { EmptyState }
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, error, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-card border bg-surface-raised px-3 py-2 text-sm text-fg placeholder:text-fg-subtle transition-colors',
|
||||
'border-border hover:border-border/80',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/50',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error && 'border-rose-500/50 focus:ring-rose-500/30 focus:border-rose-500/50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||
({ className, required, children, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium text-fg-muted leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{required && <span className="ml-1 text-rose-400">*</span>}
|
||||
</label>
|
||||
)
|
||||
)
|
||||
Label.displayName = 'Label'
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,132 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Modal = DialogPrimitive.Root
|
||||
const ModalTrigger = DialogPrimitive.Trigger
|
||||
const ModalPortal = DialogPrimitive.Portal
|
||||
const ModalClose = DialogPrimitive.Close
|
||||
|
||||
const ModalOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-fade-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ModalOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const ModalContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}
|
||||
>(({ className, children, size = 'md', ...props }, ref) => {
|
||||
const sizes = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
}
|
||||
return (
|
||||
<ModalPortal>
|
||||
<ModalOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
|
||||
'w-full max-h-[90vh] overflow-y-auto rounded-card border border-border bg-surface shadow-card animate-scale-in',
|
||||
'focus:outline-none',
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-accent/40 focus:ring-offset-2 focus:ring-offset-surface disabled:pointer-events-none">
|
||||
<X className="h-4 w-4 text-fg-muted" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</ModalPortal>
|
||||
)
|
||||
})
|
||||
ModalContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const ModalHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col gap-1.5 p-5 pb-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ModalHeader.displayName = 'ModalHeader'
|
||||
|
||||
const ModalTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-base font-semibold text-fg', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ModalTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const ModalDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-fg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ModalDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
const ModalBody = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-5', className)} {...props} />
|
||||
))
|
||||
ModalBody.displayName = 'ModalBody'
|
||||
|
||||
const ModalFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center justify-end gap-3 p-5 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ModalFooter.displayName = 'ModalFooter'
|
||||
|
||||
export {
|
||||
Modal,
|
||||
ModalPortal,
|
||||
ModalOverlay,
|
||||
ModalTrigger,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalDescription,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
const PageHeader = React.forwardRef<HTMLDivElement, PageHeaderProps>(
|
||||
({ className, title, description, actions, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl font-bold text-fg tracking-tight">{title}</h1>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-fg-muted">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex items-center gap-2 mt-3 sm:mt-0 shrink-0">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
PageHeader.displayName = 'PageHeader'
|
||||
|
||||
export { PageHeader }
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { ChevronDown, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between rounded-card border border-border bg-surface-raised px-3 py-2 text-sm text-fg placeholder:text-fg-subtle transition-colors',
|
||||
'hover:border-border/80 focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/50',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
' [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 text-fg-subtle" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-card border border-border bg-surface shadow-card animate-scale-in',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center rounded px-2 py-1.5 text-sm text-fg-muted outline-none',
|
||||
'hover:bg-surface-raised hover:text-fg',
|
||||
'focus:bg-surface-raised focus:text-fg',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4 text-accent" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'text' | 'circular' | 'rectangular'
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
}
|
||||
|
||||
const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
|
||||
({ className, variant = 'rectangular', width, height, style, ...props }, ref) => {
|
||||
const baseClass =
|
||||
variant === 'circular'
|
||||
? 'rounded-full'
|
||||
: variant === 'text'
|
||||
? 'rounded'
|
||||
: 'rounded-card'
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'animate-pulse bg-surface-raised',
|
||||
baseClass,
|
||||
className
|
||||
)}
|
||||
style={{ width, height, ...style }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Skeleton.displayName = 'Skeleton'
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Spinner = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { size?: 'sm' | 'md' | 'lg' }
|
||||
>(({ className, size = 'md', ...props }, ref) => {
|
||||
const sizes = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-6 w-6',
|
||||
lg: 'h-8 w-8',
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn('animate-spin text-accent', sizes[size], className)}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-full w-full"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
Spinner.displayName = 'Spinner'
|
||||
|
||||
export { Spinner }
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react'
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-canvas',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-accent data-[state=unchecked]:bg-surface-raised data-[state=unchecked]:border-border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-fg-subtle shadow-lg ring-0 transition-transform',
|
||||
'translate-x-0 data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitive.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,118 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = 'Table'
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = 'TableBody'
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-t bg-surface-raised font-medium [&>tr]:last:border-b-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = 'TableFooter'
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b border-border/50 transition-colors',
|
||||
'hover:bg-surface-raised/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = 'TableRow'
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 px-4 text-left align-middle text-xs font-semibold text-fg-muted uppercase tracking-wider',
|
||||
'[&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = 'TableHead'
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('px-4 py-3 align-middle text-sm text-fg', '[&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = 'TableCell'
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn('mt-4 text-sm text-fg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = 'TableCaption'
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, error, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-card border bg-surface-raised px-3 py-2 text-sm text-fg placeholder:text-fg-subtle transition-colors resize-none',
|
||||
'border-border hover:border-border/80',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/50',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error && 'border-rose-500/50 focus:ring-rose-500/30 focus:border-rose-500/50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,36 @@
|
||||
export { Button, buttonVariants } from './Button'
|
||||
export { Badge, badgeVariants } from './Badge'
|
||||
export { Spinner } from './Spinner'
|
||||
export { Skeleton } from './Skeleton'
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardBody, CardFooter } from './Card'
|
||||
export { Input } from './Input'
|
||||
export { Label } from './Label'
|
||||
export { Textarea } from './Textarea'
|
||||
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem } from './Select'
|
||||
export { Switch } from './Switch'
|
||||
export {
|
||||
Modal,
|
||||
ModalPortal,
|
||||
ModalOverlay,
|
||||
ModalTrigger,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalDescription,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from './Modal'
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
} from './Table'
|
||||
export { EmptyState } from './EmptyState'
|
||||
export { PageHeader } from './PageHeader'
|
||||
export { CopyButton } from './CopyButton'
|
||||
Reference in New Issue
Block a user