new-api/web/default/src/features/keys/components/api-key-group-combobox.tsx

187 lines
6.6 KiB
TypeScript
Vendored

import { useMemo, useState } from 'react'
import { Check, ChevronsUpDown } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
export type ApiKeyGroupOption = {
value: string
label: string
desc?: string
ratio?: number | string
}
type ApiKeyGroupComboboxProps = {
options: ApiKeyGroupOption[]
value?: string
onValueChange: (value: string) => void
placeholder?: string
disabled?: boolean
}
function formatGroupRatio(ratio: ApiKeyGroupOption['ratio'], ratioLabel: string) {
if (ratio === undefined || ratio === null || ratio === '') return null
return `${ratio}x ${ratioLabel}`
}
function getRatioBadgeClassName(ratio: ApiKeyGroupOption['ratio']) {
if (typeof ratio !== 'number') {
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
}
if (ratio > 5) {
return 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300'
}
if (ratio > 3) {
return 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-950/40 dark:text-orange-300'
}
if (ratio > 1) {
return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-300'
}
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
}
function GroupRatioBadge({ ratio }: { ratio: ApiKeyGroupOption['ratio'] }) {
const { t } = useTranslation()
const label = formatGroupRatio(ratio, t('Ratio'))
if (!label) return null
return (
<Badge
variant='outline'
className={cn(
'max-w-24 shrink-0 truncate text-[10px] sm:max-w-none sm:text-xs',
getRatioBadgeClassName(ratio)
)}
>
{label}
</Badge>
)
}
export function ApiKeyGroupCombobox({
options,
value,
onValueChange,
placeholder,
disabled,
}: ApiKeyGroupComboboxProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState('')
const selectedOption = options.find((option) => option.value === value)
const filteredOptions = useMemo(() => {
const search = searchValue.trim().toLowerCase()
if (!search) return options
return options.filter((option) => {
const ratioText = String(option.ratio ?? '').toLowerCase()
return (
option.value.toLowerCase().includes(search) ||
option.label.toLowerCase().includes(search) ||
option.desc?.toLowerCase().includes(search) ||
ratioText.includes(search)
)
})
}, [options, searchValue])
const handleSelect = (selectedValue: string) => {
onValueChange(selectedValue)
setOpen(false)
setSearchValue('')
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type='button'
variant='outline'
role='combobox'
aria-expanded={open}
disabled={disabled}
className='border-input bg-muted/40 h-auto min-h-14 w-full justify-between gap-2 rounded-lg px-3 py-2 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 hover:bg-muted/55 hover:text-foreground active:bg-background data-[state=open]:border-ring data-[state=open]:bg-background data-[state=open]:ring-ring/20 data-[state=open]:ring-[3px] sm:min-h-20 sm:gap-3 sm:px-4 sm:py-3'
>
<span className='flex min-w-0 flex-1 items-center justify-between gap-2 sm:gap-3'>
<span className='min-w-0'>
<span className='block truncate font-medium'>
{selectedOption?.value || placeholder || t('Select a group')}
</span>
{selectedOption?.desc && (
<span className='text-muted-foreground block truncate text-[11px] sm:text-xs'>
{selectedOption.desc}
</span>
)}
</span>
<span className='hidden sm:block'>
<GroupRatioBadge ratio={selectedOption?.ratio} />
</span>
</span>
<ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent
className='data-[state=closed]:zoom-out-100 data-[state=open]:zoom-in-100 data-[side=bottom]:slide-in-from-top-0 data-[side=left]:slide-in-from-right-0 data-[side=right]:slide-in-from-left-0 data-[side=top]:slide-in-from-bottom-0 w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-xl p-0 shadow-lg data-[state=closed]:duration-75 data-[state=open]:duration-100'
onWheel={(event) => event.stopPropagation()}
onTouchMove={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<Command shouldFilter={false}>
<CommandInput
placeholder={t('Search...')}
value={searchValue}
onValueChange={setSearchValue}
/>
<CommandList className='max-h-[360px]'>
<CommandEmpty>{t('No group found.')}</CommandEmpty>
<CommandGroup>
{filteredOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={handleSelect}
className='items-start gap-3 rounded-lg px-3 py-3 transition-colors data-[selected=true]:bg-muted'
>
<Check
className={cn(
'mt-0.5 h-4 w-4',
value === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
<span className='min-w-0 flex-1'>
<span className='block truncate font-medium'>
{option.value}
</span>
{option.desc && (
<span className='text-muted-foreground block truncate text-xs'>
{option.desc}
</span>
)}
</span>
<GroupRatioBadge ratio={option.ratio} />
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}