Initial commit: TaskPool React panel
- React frontend with route-level code splitting - Backend rebranded from Baihu to TaskPool - DB brand migration script and local compatibility
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type BadgeTone = 'default' | 'success' | 'warn' | 'danger' | 'info'
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
tone = 'default',
|
||||
}: {
|
||||
children: ReactNode
|
||||
tone?: BadgeTone
|
||||
}) {
|
||||
const tones: Record<BadgeTone, string> = {
|
||||
default: 'border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]',
|
||||
success:
|
||||
'border-[color-mix(in_srgb,var(--success)_25%,transparent)] bg-[color-mix(in_srgb,var(--success)_12%,transparent)] text-[var(--success)]',
|
||||
warn:
|
||||
'border-[color-mix(in_srgb,var(--warning)_25%,transparent)] bg-[color-mix(in_srgb,var(--warning)_12%,transparent)] text-[var(--warning)]',
|
||||
danger:
|
||||
'border-[color-mix(in_srgb,var(--danger)_25%,transparent)] bg-[color-mix(in_srgb,var(--danger)_12%,transparent)] text-[var(--danger)]',
|
||||
info:
|
||||
'border-[color-mix(in_srgb,var(--info)_25%,transparent)] bg-[color-mix(in_srgb,var(--info)_12%,transparent)] text-[var(--info)]',
|
||||
}
|
||||
return (
|
||||
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-medium', tones[tone])}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ButtonHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ButtonVariant = 'default' | 'secondary' | 'ghost' | 'danger' | 'outline'
|
||||
export type ButtonSize = 'sm' | 'md' | 'icon'
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
className,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn('ui-btn', `ui-btn-${variant}`, `ui-btn-${size}`, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
hoverable,
|
||||
onClick,
|
||||
...rest
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
hoverable?: boolean
|
||||
onClick?: HTMLAttributes<HTMLDivElement>['onClick']
|
||||
} & Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'className' | 'onClick'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-[var(--border)] bg-[var(--bg-secondary)] shadow-[var(--shadow-sm)] transition-[background,border-color] duration-150',
|
||||
hoverable && 'hover:border-[var(--border-hover)] hover:bg-[var(--bg-tertiary)]',
|
||||
onClick && 'cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Checkbox({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
className,
|
||||
id,
|
||||
}: {
|
||||
checked: boolean
|
||||
onCheckedChange: (v: boolean) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
id?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'inline-flex h-4 w-4 items-center justify-center rounded border transition',
|
||||
checked
|
||||
? 'border-transparent bg-[var(--bg-accent)] text-[var(--text-on-accent)]'
|
||||
: 'border-[var(--border-hover)] bg-[var(--bg-secondary)]',
|
||||
disabled && 'opacity-55 cursor-not-allowed',
|
||||
className,
|
||||
)}
|
||||
onClick={() => !disabled && onCheckedChange(!checked)}
|
||||
>
|
||||
{checked ? <Check size={12} strokeWidth={2.5} /> : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function Empty({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="px-4 py-12 text-center">
|
||||
<div className="mx-auto mb-3 h-10 w-10 rounded-lg border border-[var(--border)] bg-[var(--bg-tertiary)] opacity-70" />
|
||||
<p className="text-sm text-[var(--text-muted)]">{text}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
children,
|
||||
hint,
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
hint?: string
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--text-secondary)]">{label}</span>
|
||||
{children}
|
||||
{hint ? <span className="block text-[11px] text-[var(--text-muted)]">{hint}</span> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export { cn } from '@/lib/utils'
|
||||
export { Button } from './button'
|
||||
export type { ButtonProps, ButtonVariant, ButtonSize } from './button'
|
||||
export { Input } from './input'
|
||||
export { Textarea } from './textarea'
|
||||
export { Select } from './select'
|
||||
export type { SelectProps, SelectOption } from './select'
|
||||
export { Card } from './card'
|
||||
export { Badge } from './badge'
|
||||
export type { BadgeTone } from './badge'
|
||||
export { Modal } from './modal'
|
||||
export { Table, Th, Td } from './table'
|
||||
export { Field } from './field'
|
||||
export { PageHeader } from './page-header'
|
||||
export { Empty } from './empty'
|
||||
export { Loading } from './loading'
|
||||
export { SectionLabel } from './section-label'
|
||||
export { Switch } from './switch'
|
||||
export { Tabs } from './tabs'
|
||||
export { Checkbox } from './checkbox'
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { InputHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input {...props} className={cn('ui-control h-9 px-3', className)} />
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function Loading({ text = '加载中...' }: { text?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 px-4 py-12 text-sm text-[var(--text-muted)]">
|
||||
<span className="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-[var(--border-hover)] border-t-[var(--text-secondary)]" />
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
onClose,
|
||||
footer,
|
||||
className,
|
||||
size = 'md',
|
||||
}: {
|
||||
open: boolean
|
||||
title: string
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
size?: 'md' | 'lg' | 'xl' | 'full'
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
const sizes = {
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
full: 'max-w-[95vw]',
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/55 p-4 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={`max-h-[90vh] w-full overflow-auto rounded-xl border border-[var(--border)] bg-[var(--bg-secondary)] shadow-[var(--shadow-lg)] ${sizes[size]} ${className || ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--border)] px-4 py-3">
|
||||
<h3 className="text-[15px] font-semibold tracking-tight">{title}</h3>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3 p-4">{children}</div>
|
||||
{footer ? (
|
||||
<div className="flex justify-end gap-2 border-t border-[var(--border)] px-4 py-3">{footer}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
subtitle,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
subtitle?: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-baseline gap-3">
|
||||
<h1 className="m-0 text-2xl font-bold leading-tight tracking-tight text-[var(--text-primary)]">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle ? <span className="text-[13px] text-[var(--text-muted)]">{subtitle}</span> : null}
|
||||
</div>
|
||||
<div className="h-[3px] w-8 rounded-sm bg-[var(--bg-accent)]" />
|
||||
{description ? <p className="mt-2.5 text-sm text-[var(--text-muted)]">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-1.5">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function SectionLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<h2 className="mb-3 text-[12px] font-medium uppercase tracking-wide text-[var(--text-muted)]">
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
Children,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ButtonHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type SelectOption = {
|
||||
value: string
|
||||
label: ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type ChangeHandler = (event: { target: { value: string } }) => void
|
||||
|
||||
export interface SelectProps {
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
onChange?: ChangeHandler
|
||||
onValueChange?: (value: string) => void
|
||||
options?: SelectOption[]
|
||||
children?: ReactNode
|
||||
placeholder?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
name?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
function parseOptionsFromChildren(children: ReactNode): SelectOption[] {
|
||||
const list: SelectOption[] = []
|
||||
Children.forEach(children, (child) => {
|
||||
if (!isValidElement(child)) return
|
||||
if (child.type !== 'option') return
|
||||
const props = child.props as {
|
||||
value?: string | number
|
||||
disabled?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
list.push({
|
||||
value: props.value == null ? '' : String(props.value),
|
||||
label: props.children,
|
||||
disabled: Boolean(props.disabled),
|
||||
})
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
defaultValue = '',
|
||||
onChange,
|
||||
onValueChange,
|
||||
options,
|
||||
children,
|
||||
placeholder = '请选择',
|
||||
className,
|
||||
disabled,
|
||||
name,
|
||||
id,
|
||||
}: SelectProps) {
|
||||
const reactId = useId()
|
||||
const listboxId = id || `select-${reactId}`
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [uncontrolled, setUncontrolled] = useState(defaultValue)
|
||||
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({})
|
||||
|
||||
const parsed = useMemo(() => {
|
||||
if (options?.length) return options
|
||||
return parseOptionsFromChildren(children)
|
||||
}, [options, children])
|
||||
|
||||
const current = value !== undefined ? value : uncontrolled
|
||||
const selected = parsed.find((o) => o.value === current)
|
||||
const display = selected?.label ?? (current ? current : null)
|
||||
|
||||
function commit(next: string) {
|
||||
if (value === undefined) setUncontrolled(next)
|
||||
onValueChange?.(next)
|
||||
onChange?.({ target: { value: next } })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function updateMenuPosition() {
|
||||
const el = triggerRef.current
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const viewportH = window.innerHeight
|
||||
const spaceBelow = viewportH - rect.bottom
|
||||
const openUp = spaceBelow < 220 && rect.top > spaceBelow
|
||||
setMenuStyle({
|
||||
position: 'fixed',
|
||||
left: rect.left,
|
||||
width: Math.max(rect.width, 140),
|
||||
zIndex: 80,
|
||||
...(openUp
|
||||
? { bottom: viewportH - rect.top + 6, top: 'auto' }
|
||||
: { top: rect.bottom + 6, bottom: 'auto' }),
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
updateMenuPosition()
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
const t = e.target as Node
|
||||
if (rootRef.current?.contains(t) || menuRef.current?.contains(t)) return
|
||||
setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
const onReposition = () => updateMenuPosition()
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
document.addEventListener('keydown', onKey)
|
||||
window.addEventListener('resize', onReposition)
|
||||
window.addEventListener('scroll', onReposition, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDoc)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('resize', onReposition)
|
||||
window.removeEventListener('scroll', onReposition, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={cn('relative w-full', className)}>
|
||||
{name ? <input type="hidden" name={name} value={current} /> : null}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
id={listboxId}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className={cn('ui-control ui-select-trigger h-9 px-3 text-left', open && 'ui-control-open')}
|
||||
onClick={() => {
|
||||
if (disabled) return
|
||||
setOpen((v) => !v)
|
||||
}}
|
||||
>
|
||||
<span className={cn('min-w-0 flex-1 truncate', !display && 'text-[var(--text-muted)]')}>
|
||||
{display ?? placeholder}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
strokeWidth={1.5}
|
||||
className={cn('shrink-0 text-[var(--text-muted)] transition-transform', open && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="listbox"
|
||||
aria-labelledby={listboxId}
|
||||
className="ui-select-menu"
|
||||
style={menuStyle}
|
||||
>
|
||||
{parsed.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-[var(--text-muted)]">暂无选项</div>
|
||||
) : (
|
||||
parsed.map((opt) => {
|
||||
const active = opt.value === current
|
||||
return (
|
||||
<button
|
||||
key={`${opt.value}__${String(opt.label)}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
disabled={opt.disabled}
|
||||
className={cn('ui-select-item', active && 'is-active')}
|
||||
onClick={() => {
|
||||
if (opt.disabled) return
|
||||
commit(opt.value)
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{opt.label}</span>
|
||||
{active ? <Check size={14} strokeWidth={1.75} className="shrink-0 opacity-90" /> : null}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 兼容旧的原生 option 写法,无需改调用方 */
|
||||
export type NativeSelectProps = ButtonHTMLAttributes<HTMLButtonElement>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
className,
|
||||
}: {
|
||||
checked: boolean
|
||||
onCheckedChange: (v: boolean) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition',
|
||||
checked ? 'border-transparent bg-[var(--bg-accent)]' : 'border-[var(--border)] bg-[var(--bg-tertiary)]',
|
||||
disabled && 'opacity-55 cursor-not-allowed',
|
||||
className,
|
||||
)}
|
||||
onClick={() => !disabled && onCheckedChange(!checked)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform',
|
||||
checked ? 'translate-x-[18px]' : 'translate-x-[3px]',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Table({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] text-left text-sm">{children}</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Th({ children, className }: { children?: ReactNode; className?: string }) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
'border-b border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--text-muted)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
export function Td({ children, className }: { children?: ReactNode; className?: string }) {
|
||||
return (
|
||||
<td className={cn('border-b border-[var(--border)] px-3 py-2.5 align-middle text-[var(--text-primary)]', className)}>
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Tabs({
|
||||
value,
|
||||
onValueChange,
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
value: string
|
||||
onValueChange: (v: string) => void
|
||||
items: { value: string; label: string }[]
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex max-w-full items-center gap-1.5 overflow-x-auto rounded-lg border border-[var(--border)] bg-[var(--bg-tertiary)] p-1.5',
|
||||
'[scrollbar-width:thin]',
|
||||
className,
|
||||
)}
|
||||
role="tablist"
|
||||
>
|
||||
{items.map((item) => {
|
||||
const active = value === item.value
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={cn(
|
||||
'relative shrink-0 rounded-md px-3.5 py-1.5 text-[13px] font-medium whitespace-nowrap transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)]',
|
||||
active
|
||||
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-[var(--shadow-sm)] ring-1 ring-[var(--border)]'
|
||||
: 'text-[var(--text-muted)] hover:bg-[var(--bg-hover)]/60 hover:text-[var(--text-secondary)]',
|
||||
)}
|
||||
onClick={() => onValueChange(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { TextareaHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return <textarea {...props} className={cn('ui-control min-h-24 resize-y px-3 py-2', className)} />
|
||||
}
|
||||
Reference in New Issue
Block a user