feat: enhance UI and functionality in various components
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
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={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='h-auto min-h-10 w-full justify-between gap-3 px-3 py-2 text-start'
|
||||
>
|
||||
<span className='flex min-w-0 flex-1 items-center justify-between 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-xs'>
|
||||
{selectedOption.desc}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<GroupRatioBadge ratio={selectedOption?.ratio} />
|
||||
</span>
|
||||
<ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[var(--radix-popover-trigger-width)] p-0'>
|
||||
<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 px-3 py-3'
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -62,7 +62,7 @@ function useGroupRatios(): Record<string, number> {
|
||||
if (!res.success || !res.data) return {}
|
||||
const ratios: Record<string, number> = {}
|
||||
for (const [group, info] of Object.entries(res.data)) {
|
||||
if (info.ratio !== undefined) {
|
||||
if (typeof info.ratio === 'number') {
|
||||
ratios[group] = info.ratio
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ApiKeysDeleteDialog } from './api-keys-delete-dialog'
|
||||
import { ApiKeysMutateDrawer } from './api-keys-mutate-drawer'
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
@@ -5,6 +6,19 @@ import { CCSwitchDialog } from './dialogs/cc-switch-dialog'
|
||||
|
||||
export function ApiKeysDialogs() {
|
||||
const { open, setOpen, currentRow, resolvedKey } = useApiKeys()
|
||||
const [lastMutateSide, setLastMutateSide] = useState<'left' | 'right'>(
|
||||
'right'
|
||||
)
|
||||
const mutateSide =
|
||||
open === 'create' ? 'left' : open === 'update' ? 'right' : lastMutateSide
|
||||
|
||||
useEffect(() => {
|
||||
if (open === 'create') {
|
||||
setLastMutateSide('left')
|
||||
} else if (open === 'update') {
|
||||
setLastMutateSide('right')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -12,6 +26,7 @@ export function ApiKeysDialogs() {
|
||||
open={open === 'create' || open === 'update'}
|
||||
onOpenChange={(isOpen) => !isOpen && setOpen(null)}
|
||||
currentRow={open === 'update' ? currentRow || undefined : undefined}
|
||||
side={mutateSide}
|
||||
/>
|
||||
<ApiKeysDeleteDialog />
|
||||
<CCSwitchDialog
|
||||
|
||||
@@ -23,13 +23,6 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
@@ -53,18 +46,24 @@ import {
|
||||
transformApiKeyToFormDefaults,
|
||||
} from '../lib'
|
||||
import { type ApiKey } from '../types'
|
||||
import {
|
||||
ApiKeyGroupCombobox,
|
||||
type ApiKeyGroupOption,
|
||||
} from './api-key-group-combobox'
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
|
||||
type ApiKeyMutateDrawerProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
currentRow?: ApiKey
|
||||
side?: 'left' | 'right'
|
||||
}
|
||||
|
||||
export function ApiKeysMutateDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentRow,
|
||||
side = 'right',
|
||||
}: ApiKeyMutateDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
const isUpdate = !!currentRow
|
||||
@@ -88,14 +87,22 @@ export function ApiKeysMutateDrawer({
|
||||
|
||||
const models = modelsData?.data || []
|
||||
const groupsRaw = groupsData?.data || {}
|
||||
const groups = Object.entries(groupsRaw).map(([key, info]) => ({
|
||||
value: key,
|
||||
label: info.desc || key,
|
||||
}))
|
||||
const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw).map(
|
||||
([key, info]) => ({
|
||||
value: key,
|
||||
label: key,
|
||||
desc: info.desc || key,
|
||||
ratio: info.ratio,
|
||||
})
|
||||
)
|
||||
|
||||
// Add auto group if configured
|
||||
if (!groups.some((g) => g.value === 'auto')) {
|
||||
groups.unshift({ value: 'auto', label: t('Auto (Circuit Breaker)') })
|
||||
groups.unshift({
|
||||
value: 'auto',
|
||||
label: 'auto',
|
||||
desc: t('Auto (Circuit Breaker)'),
|
||||
})
|
||||
}
|
||||
|
||||
const form = useForm<ApiKeyFormValues>({
|
||||
@@ -187,10 +194,9 @@ export function ApiKeysMutateDrawer({
|
||||
form.setValue('expired_time', now)
|
||||
}
|
||||
|
||||
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay()
|
||||
const { meta: currencyMeta } = getCurrencyDisplay()
|
||||
const currencyLabel = getCurrencyLabel()
|
||||
const tokensOnly =
|
||||
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
|
||||
const tokensOnly = currencyMeta.kind === 'tokens'
|
||||
const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel })
|
||||
const quotaPlaceholder = tokensOnly
|
||||
? t('Enter quota in tokens')
|
||||
@@ -206,7 +212,10 @@ export function ApiKeysMutateDrawer({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SheetContent className='flex w-full flex-col sm:max-w-[600px]'>
|
||||
<SheetContent
|
||||
side={side}
|
||||
className='flex w-full flex-col sm:max-w-[600px]'
|
||||
>
|
||||
<SheetHeader className='text-start'>
|
||||
<SheetTitle>
|
||||
{isUpdate ? t('Update API Key') : t('Create API Key')}
|
||||
@@ -244,20 +253,14 @@ export function ApiKeysMutateDrawer({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Group')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('Select a group')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{groups.map((group) => (
|
||||
<SelectItem key={group.value} value={group.value}>
|
||||
{group.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormControl>
|
||||
<ApiKeyGroupCombobox
|
||||
options={groups}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t('Select a group')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Auto group enables circuit breaker mechanism')}
|
||||
</FormDescription>
|
||||
|
||||
+17
-11
@@ -38,6 +38,7 @@ import { getApiKeys, searchApiKeys } from '../api'
|
||||
import { API_KEY_STATUS_OPTIONS, ERROR_MESSAGES } from '../constants'
|
||||
import { type ApiKey } from '../types'
|
||||
import { useApiKeysColumns } from './api-keys-columns'
|
||||
import { ApiKeysPrimaryButtons } from './api-keys-primary-buttons'
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||
|
||||
@@ -160,17 +161,22 @@ export function ApiKeysTable() {
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-4'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder={t('Filter by name or key...')}
|
||||
filters={[
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: API_KEY_STATUS_OPTIONS,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
|
||||
<ApiKeysPrimaryButtons />
|
||||
<div className='min-w-0 sm:flex sm:justify-end'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder={t('Filter by name or key...')}
|
||||
filters={[
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: API_KEY_STATUS_OPTIONS,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<MobileCardList
|
||||
table={table}
|
||||
|
||||
Reference in New Issue
Block a user