claude-code

This commit is contained in:
ashutoshpythoncs@gmail.com
2026-03-31 18:58:05 +05:30
parent a2a44a5841
commit b564857c0b
2148 changed files with 564518 additions and 2 deletions

View File

@@ -0,0 +1,6 @@
"use client";
// Re-exports for backwards compatibility.
// The notification system lives in web/components/notifications/ and web/lib/notifications.ts.
export { ToastProvider } from "@/components/notifications/ToastProvider";
export { useToast } from "@/hooks/useToast";

View File

@@ -0,0 +1,82 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const avatarVariants = cva(
'relative inline-flex items-center justify-center rounded-full overflow-hidden font-medium select-none flex-shrink-0',
{
variants: {
size: {
xs: 'h-6 w-6 text-[10px]',
sm: 'h-8 w-8 text-xs',
md: 'h-10 w-10 text-sm',
lg: 'h-12 w-12 text-base',
xl: 'h-16 w-16 text-lg',
},
},
defaultVariants: { size: 'md' },
}
)
export interface AvatarProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof avatarVariants> {
src?: string
alt?: string
name?: string
}
function getInitials(name: string): string {
return name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((n) => n[0].toUpperCase())
.join('')
}
// Deterministic colour from name
function getAvatarColor(name: string): string {
const colours = [
'bg-brand-700 text-brand-200',
'bg-violet-800 text-violet-200',
'bg-indigo-800 text-indigo-200',
'bg-blue-800 text-blue-200',
'bg-cyan-800 text-cyan-200',
'bg-teal-800 text-teal-200',
'bg-emerald-800 text-emerald-200',
'bg-amber-800 text-amber-200',
'bg-rose-800 text-rose-200',
]
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) & 0xffffffff
}
return colours[Math.abs(hash) % colours.length]
}
function Avatar({ className, size, src, alt, name, ...props }: AvatarProps) {
const [imgError, setImgError] = React.useState(false)
const showImage = src && !imgError
const initials = name ? getInitials(name) : '?'
const colorClass = name ? getAvatarColor(name) : 'bg-surface-700 text-surface-300'
return (
<span className={cn(avatarVariants({ size, className }))} {...props}>
{showImage ? (
<img
src={src}
alt={alt ?? name ?? 'Avatar'}
className="h-full w-full object-cover"
onError={() => setImgError(true)}
/>
) : (
<span className={cn('flex h-full w-full items-center justify-center', colorClass)} aria-label={name}>
{initials}
</span>
)}
</span>
)
}
export { Avatar, avatarVariants }

View File

@@ -0,0 +1,56 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors',
{
variants: {
variant: {
default: 'bg-surface-800 text-surface-300 border border-surface-700',
success: 'bg-success-bg text-success border border-success/20',
error: 'bg-error-bg text-error border border-error/20',
warning: 'bg-warning-bg text-warning border border-warning/20',
info: 'bg-info-bg text-info border border-info/20',
brand: 'bg-brand-500/15 text-brand-300 border border-brand-500/25',
outline: 'border border-surface-600 text-surface-400',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {
dot?: boolean
}
function Badge({ className, variant, dot = false, children, ...props }: BadgeProps) {
const dotColors: Record<string, string> = {
default: 'bg-surface-400',
success: 'bg-success',
error: 'bg-error',
warning: 'bg-warning',
info: 'bg-info',
brand: 'bg-brand-400',
outline: 'bg-surface-500',
}
const dotColor = dotColors[variant ?? 'default'] ?? dotColors.default
return (
<span className={cn(badgeVariants({ variant, className }))} {...props}>
{dot && (
<span
className={cn('h-1.5 w-1.5 rounded-full flex-shrink-0', dotColor)}
aria-hidden="true"
/>
)}
{children}
</span>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,73 @@
'use client'
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors duration-[var(--transition-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 select-none',
{
variants: {
variant: {
primary: 'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800',
secondary: 'bg-surface-800 text-surface-100 border border-surface-700 hover:bg-surface-700',
ghost: 'text-surface-400 hover:bg-surface-800 hover:text-surface-100',
danger: 'bg-red-600 text-white hover:bg-red-700 active:bg-red-800',
// Legacy aliases
default: 'bg-brand-600 text-white hover:bg-brand-700',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-surface-700 bg-transparent hover:bg-surface-800 text-surface-200',
link: 'text-brand-400 underline-offset-4 hover:underline',
},
size: {
sm: 'h-7 px-3 text-xs rounded',
md: 'h-9 px-4 text-sm',
lg: 'h-11 px-6 text-base',
// Legacy aliases
default: 'h-9 px-4 py-2',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
loading?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading = false, children, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
const spinnerSize = size === 'sm' ? 12 : size === 'lg' ? 18 : 14
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...props}
>
{loading && (
<Loader2
className="animate-spin flex-shrink-0"
size={spinnerSize}
aria-hidden="true"
/>
)}
{children}
</Comp>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,131 @@
'use client'
import * as React from 'react'
import * as RadixDialog from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const dialogContentVariants = cva(
[
'relative z-50 grid w-full gap-4 rounded-lg border border-surface-700',
'bg-surface-900 p-6 shadow-lg',
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
].join(' '),
{
variants: {
size: {
sm: 'max-w-sm',
md: 'max-w-lg',
lg: 'max-w-2xl',
full: 'max-w-[calc(100vw-2rem)] h-[calc(100vh-4rem)]',
},
},
defaultVariants: {
size: 'md',
},
}
)
const Dialog = RadixDialog.Root
const DialogTrigger = RadixDialog.Trigger
const DialogPortal = RadixDialog.Portal
const DialogClose = RadixDialog.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof RadixDialog.Overlay>,
React.ComponentPropsWithoutRef<typeof RadixDialog.Overlay>
>(({ className, ...props }, ref) => (
<RadixDialog.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
'data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out',
className
)}
{...props}
/>
))
DialogOverlay.displayName = RadixDialog.Overlay.displayName
interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof RadixDialog.Content>,
VariantProps<typeof dialogContentVariants> {
showClose?: boolean
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof RadixDialog.Content>,
DialogContentProps
>(({ className, children, size, showClose = true, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<RadixDialog.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
dialogContentVariants({ size, className })
)}
{...props}
>
{children}
{showClose && (
<DialogClose className="absolute right-4 top-4 rounded-sm text-surface-500 hover:text-surface-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<X className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Close</span>
</DialogClose>
)}
</RadixDialog.Content>
</DialogPortal>
))
DialogContent.displayName = RadixDialog.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col gap-1.5', className)} {...props} />
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof RadixDialog.Title>,
React.ComponentPropsWithoutRef<typeof RadixDialog.Title>
>(({ className, ...props }, ref) => (
<RadixDialog.Title
ref={ref}
className={cn('text-lg font-semibold text-surface-50', className)}
{...props}
/>
))
DialogTitle.displayName = RadixDialog.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof RadixDialog.Description>,
React.ComponentPropsWithoutRef<typeof RadixDialog.Description>
>(({ className, ...props }, ref) => (
<RadixDialog.Description
ref={ref}
className={cn('text-sm text-surface-400', className)}
{...props}
/>
))
DialogDescription.displayName = RadixDialog.Description.displayName
export {
Dialog,
DialogTrigger,
DialogPortal,
DialogOverlay,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,191 @@
'use client'
import * as React from 'react'
import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu'
import { Check, ChevronRight, Circle } from 'lucide-react'
import { cn } from '@/lib/utils'
const DropdownMenu = RadixDropdownMenu.Root
const DropdownMenuTrigger = RadixDropdownMenu.Trigger
const DropdownMenuGroup = RadixDropdownMenu.Group
const DropdownMenuPortal = RadixDropdownMenu.Portal
const DropdownMenuSub = RadixDropdownMenu.Sub
const DropdownMenuRadioGroup = RadixDropdownMenu.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.SubTrigger>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.SubTrigger> & { inset?: boolean }
>(({ className, inset, children, ...props }, ref) => (
<RadixDropdownMenu.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm',
'text-surface-200 outline-none focus:bg-surface-800 data-[state=open]:bg-surface-800',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4 text-surface-500" aria-hidden="true" />
</RadixDropdownMenu.SubTrigger>
))
DropdownMenuSubTrigger.displayName = RadixDropdownMenu.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.SubContent>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.SubContent>
>(({ className, ...props }, ref) => (
<RadixDropdownMenu.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-surface-700',
'bg-surface-850 p-1 text-surface-100 shadow-lg',
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = RadixDropdownMenu.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.Content>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<RadixDropdownMenu.Portal>
<RadixDropdownMenu.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-surface-700',
'bg-surface-850 p-1 text-surface-100 shadow-lg',
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
className
)}
{...props}
/>
</RadixDropdownMenu.Portal>
))
DropdownMenuContent.displayName = RadixDropdownMenu.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.Item>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Item> & { inset?: boolean; destructive?: boolean }
>(({ className, inset, destructive, ...props }, ref) => (
<RadixDropdownMenu.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm',
'outline-none transition-colors focus:bg-surface-800',
destructive
? 'text-red-400 focus:text-red-300'
: 'text-surface-200 focus:text-surface-50',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = RadixDropdownMenu.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<RadixDropdownMenu.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm',
'text-surface-200 outline-none transition-colors focus:bg-surface-800 focus:text-surface-50',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<RadixDropdownMenu.ItemIndicator>
<Check className="h-4 w-4 text-brand-400" aria-hidden="true" />
</RadixDropdownMenu.ItemIndicator>
</span>
{children}
</RadixDropdownMenu.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = RadixDropdownMenu.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.RadioItem>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.RadioItem>
>(({ className, children, ...props }, ref) => (
<RadixDropdownMenu.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm',
'text-surface-200 outline-none transition-colors focus:bg-surface-800 focus:text-surface-50',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<RadixDropdownMenu.ItemIndicator>
<Circle className="h-2 w-2 fill-brand-400 text-brand-400" aria-hidden="true" />
</RadixDropdownMenu.ItemIndicator>
</span>
{children}
</RadixDropdownMenu.RadioItem>
))
DropdownMenuRadioItem.displayName = RadixDropdownMenu.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.Label>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<RadixDropdownMenu.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-xs font-semibold text-surface-500 uppercase tracking-wider',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = RadixDropdownMenu.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.Separator>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Separator>
>(({ className, ...props }, ref) => (
<RadixDropdownMenu.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-surface-700', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = RadixDropdownMenu.Separator.displayName
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
<span className={cn('ml-auto text-xs tracking-widest text-surface-500', className)} {...props} />
)
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,79 @@
'use client'
import * as React from 'react'
import { Search } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
helper?: string
variant?: 'default' | 'search'
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, helper, variant = 'default', id, ...props }, ref) => {
const inputId = id ?? React.useId()
const errorId = `${inputId}-error`
const helperId = `${inputId}-helper`
const describedBy = [
error ? errorId : null,
helper ? helperId : null,
]
.filter(Boolean)
.join(' ')
return (
<div className="flex flex-col gap-1.5">
{label && (
<label
htmlFor={inputId}
className="text-sm font-medium text-surface-200"
>
{label}
</label>
)}
<div className="relative">
{variant === 'search' && (
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 text-surface-500 pointer-events-none"
size={15}
aria-hidden="true"
/>
)}
<input
id={inputId}
ref={ref}
aria-describedby={describedBy || undefined}
aria-invalid={error ? true : undefined}
className={cn(
'flex h-9 w-full rounded-md border bg-surface-900 px-3 py-1 text-sm text-surface-100',
'border-surface-700 placeholder:text-surface-500',
'transition-colors duration-[var(--transition-fast)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:border-transparent',
'disabled:cursor-not-allowed disabled:opacity-50',
variant === 'search' && 'pl-9',
error && 'border-red-500 focus-visible:ring-red-500',
className
)}
{...props}
/>
</div>
{error && (
<p id={errorId} className="text-xs text-red-400" role="alert">
{error}
</p>
)}
{helper && !error && (
<p id={helperId} className="text-xs text-surface-500">
{helper}
</p>
)}
</div>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,161 @@
'use client'
import * as React from 'react'
import * as RadixSelect from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = RadixSelect.Root
const SelectGroup = RadixSelect.Group
const SelectValue = RadixSelect.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof RadixSelect.Trigger>,
React.ComponentPropsWithoutRef<typeof RadixSelect.Trigger>
>(({ className, children, ...props }, ref) => (
<RadixSelect.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between rounded-md border border-surface-700',
'bg-surface-900 px-3 py-2 text-sm text-surface-100',
'placeholder:text-surface-500',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:border-transparent',
'disabled:cursor-not-allowed disabled:opacity-50',
'[&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<RadixSelect.Icon asChild>
<ChevronDown className="h-4 w-4 text-surface-500 flex-shrink-0" aria-hidden="true" />
</RadixSelect.Icon>
</RadixSelect.Trigger>
))
SelectTrigger.displayName = RadixSelect.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof RadixSelect.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof RadixSelect.ScrollUpButton>
>(({ className, ...props }, ref) => (
<RadixSelect.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4 text-surface-500" aria-hidden="true" />
</RadixSelect.ScrollUpButton>
))
SelectScrollUpButton.displayName = RadixSelect.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof RadixSelect.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof RadixSelect.ScrollDownButton>
>(({ className, ...props }, ref) => (
<RadixSelect.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4 text-surface-500" aria-hidden="true" />
</RadixSelect.ScrollDownButton>
))
SelectScrollDownButton.displayName = RadixSelect.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof RadixSelect.Content>,
React.ComponentPropsWithoutRef<typeof RadixSelect.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<RadixSelect.Portal>
<RadixSelect.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-surface-700',
'bg-surface-850 text-surface-100 shadow-lg',
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
position === 'popper' && [
'data-[side=bottom]:translate-y-1',
'data-[side=top]:-translate-y-1',
],
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<RadixSelect.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</RadixSelect.Viewport>
<SelectScrollDownButton />
</RadixSelect.Content>
</RadixSelect.Portal>
))
SelectContent.displayName = RadixSelect.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof RadixSelect.Label>,
React.ComponentPropsWithoutRef<typeof RadixSelect.Label>
>(({ className, ...props }, ref) => (
<RadixSelect.Label
ref={ref}
className={cn('px-2 py-1.5 text-xs font-semibold text-surface-500 uppercase tracking-wider', className)}
{...props}
/>
))
SelectLabel.displayName = RadixSelect.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof RadixSelect.Item>,
React.ComponentPropsWithoutRef<typeof RadixSelect.Item>
>(({ className, children, ...props }, ref) => (
<RadixSelect.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm',
'text-surface-200 outline-none',
'focus:bg-surface-800 focus:text-surface-50',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<RadixSelect.ItemIndicator>
<Check className="h-4 w-4 text-brand-400" aria-hidden="true" />
</RadixSelect.ItemIndicator>
</span>
<RadixSelect.ItemText>{children}</RadixSelect.ItemText>
</RadixSelect.Item>
))
SelectItem.displayName = RadixSelect.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof RadixSelect.Separator>,
React.ComponentPropsWithoutRef<typeof RadixSelect.Separator>
>(({ className, ...props }, ref) => (
<RadixSelect.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-surface-700', className)}
{...props}
/>
))
SelectSeparator.displayName = RadixSelect.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,64 @@
'use client'
import * as React from 'react'
import * as RadixTabs from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
const Tabs = RadixTabs.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof RadixTabs.List>,
React.ComponentPropsWithoutRef<typeof RadixTabs.List>
>(({ className, ...props }, ref) => (
<RadixTabs.List
ref={ref}
className={cn(
'relative inline-flex items-center border-b border-surface-800 w-full',
className
)}
{...props}
/>
))
TabsList.displayName = RadixTabs.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof RadixTabs.Trigger>,
React.ComponentPropsWithoutRef<typeof RadixTabs.Trigger>
>(({ className, ...props }, ref) => (
<RadixTabs.Trigger
ref={ref}
className={cn(
'relative inline-flex items-center justify-center gap-1.5 whitespace-nowrap',
'px-4 py-2.5 text-sm font-medium',
'text-surface-500 transition-colors duration-[var(--transition-fast)]',
'hover:text-surface-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-inset',
'disabled:pointer-events-none disabled:opacity-50',
// Animated underline via pseudo-element
'after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full',
'after:scale-x-0 after:bg-brand-500 after:transition-transform after:duration-[var(--transition-normal)]',
'data-[state=active]:text-surface-50 data-[state=active]:after:scale-x-100',
className
)}
{...props}
/>
))
TabsTrigger.displayName = RadixTabs.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof RadixTabs.Content>,
React.ComponentPropsWithoutRef<typeof RadixTabs.Content>
>(({ className, ...props }, ref) => (
<RadixTabs.Content
ref={ref}
className={cn(
'mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'data-[state=active]:animate-fade-in',
className
)}
{...props}
/>
))
TabsContent.displayName = RadixTabs.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,105 @@
'use client'
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string
error?: string
helper?: string
maxCount?: number
autoGrow?: boolean
}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, label, error, helper, maxCount, autoGrow = false, id, onChange, value, ...props }, ref) => {
const textareaId = id ?? React.useId()
const errorId = `${textareaId}-error`
const helperId = `${textareaId}-helper`
const internalRef = React.useRef<HTMLTextAreaElement>(null)
const resolvedRef = (ref as React.RefObject<HTMLTextAreaElement>) ?? internalRef
const [charCount, setCharCount] = React.useState(
typeof value === 'string' ? value.length : 0
)
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setCharCount(e.target.value.length)
if (autoGrow && resolvedRef.current) {
resolvedRef.current.style.height = 'auto'
resolvedRef.current.style.height = `${resolvedRef.current.scrollHeight}px`
}
onChange?.(e)
}
React.useEffect(() => {
if (autoGrow && resolvedRef.current) {
resolvedRef.current.style.height = 'auto'
resolvedRef.current.style.height = `${resolvedRef.current.scrollHeight}px`
}
}, [value, autoGrow, resolvedRef])
const describedBy = [error ? errorId : null, helper ? helperId : null]
.filter(Boolean)
.join(' ')
const isOverLimit = maxCount !== undefined && charCount > maxCount
return (
<div className="flex flex-col gap-1.5">
{label && (
<label htmlFor={textareaId} className="text-sm font-medium text-surface-200">
{label}
</label>
)}
<textarea
id={textareaId}
ref={resolvedRef}
value={value}
onChange={handleChange}
aria-describedby={describedBy || undefined}
aria-invalid={error ? true : undefined}
className={cn(
'flex min-h-[80px] w-full rounded-md border bg-surface-900 px-3 py-2 text-sm text-surface-100',
'border-surface-700 placeholder:text-surface-500',
'transition-colors duration-[var(--transition-fast)] resize-none',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:border-transparent',
'disabled:cursor-not-allowed disabled:opacity-50',
autoGrow && 'overflow-hidden',
error && 'border-red-500 focus-visible:ring-red-500',
className
)}
{...props}
/>
<div className="flex items-start justify-between gap-2">
<div>
{error && (
<p id={errorId} className="text-xs text-red-400" role="alert">
{error}
</p>
)}
{helper && !error && (
<p id={helperId} className="text-xs text-surface-500">
{helper}
</p>
)}
</div>
{maxCount !== undefined && (
<span
className={cn(
'text-xs tabular-nums ml-auto flex-shrink-0',
isOverLimit ? 'text-red-400' : 'text-surface-500'
)}
aria-live="polite"
>
{charCount}/{maxCount}
</span>
)}
</div>
</div>
)
}
)
Textarea.displayName = 'Textarea'
export { Textarea }

168
web/components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,168 @@
'use client'
import * as React from 'react'
import * as RadixToast from '@radix-ui/react-toast'
import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
// ── Types ────────────────────────────────────────────────────────────────────
export type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info'
export interface ToastData {
id: string
title: string
description?: string
variant?: ToastVariant
duration?: number
}
// ── Store (singleton for imperative toasts) ───────────────────────────────────
type Listener = (toasts: ToastData[]) => void
let toastList: ToastData[] = []
const listeners = new Set<Listener>()
function emit() {
listeners.forEach((fn) => fn([...toastList]))
}
export const toast = {
show(data: Omit<ToastData, 'id'>) {
const id = Math.random().toString(36).slice(2, 9)
toastList = [...toastList, { id, ...data }]
emit()
return id
},
success(title: string, description?: string) {
return this.show({ title, description, variant: 'success' })
},
error(title: string, description?: string) {
return this.show({ title, description, variant: 'error' })
},
warning(title: string, description?: string) {
return this.show({ title, description, variant: 'warning' })
},
info(title: string, description?: string) {
return this.show({ title, description, variant: 'info' })
},
dismiss(id: string) {
toastList = toastList.filter((t) => t.id !== id)
emit()
},
}
function useToastStore() {
const [toasts, setToasts] = React.useState<ToastData[]>([])
React.useEffect(() => {
setToasts([...toastList])
listeners.add(setToasts)
return () => { listeners.delete(setToasts) }
}, [])
return toasts
}
// ── Style variants ────────────────────────────────────────────────────────────
const toastVariants = cva(
[
'group pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden',
'rounded-lg border p-4 shadow-lg transition-all',
'data-[state=open]:animate-slide-up data-[state=closed]:animate-slide-down-out',
'data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)]',
'data-[swipe=cancel]:translate-x-0',
'data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=end]:animate-fade-out',
].join(' '),
{
variants: {
variant: {
default: 'bg-surface-800 border-surface-700 text-surface-100',
success: 'bg-surface-800 border-green-800/60 text-surface-100',
error: 'bg-surface-800 border-red-800/60 text-surface-100',
warning: 'bg-surface-800 border-yellow-800/60 text-surface-100',
info: 'bg-surface-800 border-blue-800/60 text-surface-100',
},
},
defaultVariants: { variant: 'default' },
}
)
const variantIcons: Record<ToastVariant, React.ReactNode> = {
default: null,
success: <CheckCircle2 className="h-4 w-4 text-green-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
error: <AlertCircle className="h-4 w-4 text-red-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
warning: <AlertTriangle className="h-4 w-4 text-yellow-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
info: <Info className="h-4 w-4 text-blue-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
}
// ── Single toast item ─────────────────────────────────────────────────────────
interface ToastItemProps extends VariantProps<typeof toastVariants> {
id: string
title: string
description?: string
duration?: number
}
function ToastItem({ id, title, description, variant = 'default', duration = 5000 }: ToastItemProps) {
const [open, setOpen] = React.useState(true)
const icon = variantIcons[variant ?? 'default']
return (
<RadixToast.Root
open={open}
onOpenChange={(o) => {
setOpen(o)
if (!o) setTimeout(() => toast.dismiss(id), 300)
}}
duration={duration}
className={cn(toastVariants({ variant }))}
>
{icon}
<div className="flex-1 min-w-0">
<RadixToast.Title className="text-sm font-medium leading-snug">{title}</RadixToast.Title>
{description && (
<RadixToast.Description className="mt-0.5 text-xs text-surface-400 leading-relaxed">
{description}
</RadixToast.Description>
)}
</div>
<RadixToast.Close
className="flex-shrink-0 text-surface-500 hover:text-surface-200 transition-colors rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Dismiss notification"
>
<X className="h-4 w-4" aria-hidden="true" />
</RadixToast.Close>
{/* Progress bar */}
<div
className="absolute bottom-0 left-0 h-0.5 w-full origin-left bg-current opacity-20"
style={{ animation: `progress ${duration}ms linear forwards` }}
aria-hidden="true"
/>
</RadixToast.Root>
)
}
// ── Provider (mount once in layout) ──────────────────────────────────────────
export function ToastProvider({ children }: { children: React.ReactNode }) {
const toasts = useToastStore()
return (
<RadixToast.Provider swipeDirection="right">
{children}
{toasts.map((t) => (
<ToastItem key={t.id} {...t} />
))}
<RadixToast.Viewport className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 w-80 focus:outline-none" />
</RadixToast.Provider>
)
}
// ── Hook (alternative to imperative API) ─────────────────────────────────────
export function useToast() {
return toast
}

View File

@@ -0,0 +1,57 @@
'use client'
import * as React from 'react'
import * as RadixTooltip from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = RadixTooltip.Provider
const Tooltip = RadixTooltip.Root
const TooltipTrigger = RadixTooltip.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof RadixTooltip.Content>,
React.ComponentPropsWithoutRef<typeof RadixTooltip.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<RadixTooltip.Portal>
<RadixTooltip.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border border-surface-700',
'bg-surface-800 px-3 py-1.5 text-xs text-surface-100 shadow-md',
'animate-scale-in data-[state=closed]:animate-scale-out',
className
)}
{...props}
/>
</RadixTooltip.Portal>
))
TooltipContent.displayName = RadixTooltip.Content.displayName
// Convenience wrapper
interface SimpleTooltipProps {
content: React.ReactNode
children: React.ReactNode
side?: 'top' | 'right' | 'bottom' | 'left'
delayDuration?: number
asChild?: boolean
}
function SimpleTooltip({
content,
children,
side = 'top',
delayDuration = 400,
asChild = false,
}: SimpleTooltipProps) {
return (
<TooltipProvider delayDuration={delayDuration}>
<Tooltip>
<TooltipTrigger asChild={asChild}>{children}</TooltipTrigger>
<TooltipContent side={side}>{content}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, SimpleTooltip }