♻️ refactor(web): consolidate design-system primitives and responsive data views

This commit is contained in:
t0ng7u
2026-07-11 05:13:16 +08:00
parent 262ab93123
commit 0918bdb49a
451 changed files with 7295 additions and 7693 deletions
@@ -0,0 +1,189 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { flexRender, type Row } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import {
DataTableCardDetails,
DataTableCardField,
} from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import { formatQuota } from '@/lib/format'
import type { ApiKey } from '../types'
function renderApiKeyCell(row: Row<ApiKey>, columnId: string) {
const cell = row
.getVisibleCells()
.find((candidate) => candidate.column.id === columnId)
if (!cell) return null
return flexRender(cell.column.columnDef.cell, cell.getContext())
}
function ApiKeyModels(props: { apiKey: ApiKey }) {
const { t } = useTranslation()
if (!props.apiKey.model_limits_enabled || !props.apiKey.model_limits) {
return <StatusBadge variant='neutral'>{t('Unlimited')}</StatusBadge>
}
const models = props.apiKey.model_limits
.split(',')
.map((model) => model.trim())
.filter(Boolean)
return (
<span className='font-mono text-xs whitespace-pre-wrap'>
{models.join(', ')}
</span>
)
}
function ApiKeyIpRestrictions(props: { apiKey: ApiKey }) {
const { t } = useTranslation()
const allowIps = props.apiKey.allow_ips?.trim()
if (!allowIps) {
return <StatusBadge variant='neutral'>{t('No restriction')}</StatusBadge>
}
const ips = allowIps
.split('\n')
.map((ip) => ip.trim())
.filter(Boolean)
return (
<span className='font-mono text-xs whitespace-pre-wrap'>
{ips.join('\n')}
</span>
)
}
export function ApiKeyCard(props: { row: Row<ApiKey> }) {
const { t } = useTranslation()
const apiKey = props.row.original
const totalQuota = apiKey.used_quota + apiKey.remain_quota
const visibleColumnIds = new Set(
props.row.getVisibleCells().map((cell) => cell.column.id)
)
const detailsCount = [
'status',
'group',
'model_limits',
'allow_ips',
'created_time',
'accessed_time',
'expired_time',
'actions',
].filter((columnId) => visibleColumnIds.has(columnId)).length
return (
<>
<div className='grid grid-cols-2 gap-x-3 gap-y-2'>
{visibleColumnIds.has('name') && (
<DataTableCardField
label={t('Name')}
span={2}
contentMode='wrap'
valueClassName='font-medium'
>
{renderApiKeyCell(props.row, 'name')}
</DataTableCardField>
)}
{visibleColumnIds.has('key') && (
<DataTableCardField label={t('API Key')} span={2} contentMode='full'>
{renderApiKeyCell(props.row, 'key')}
</DataTableCardField>
)}
{visibleColumnIds.has('quota') && (
<DataTableCardField label={t('Quota')} span={2} contentMode='full'>
{apiKey.unlimited_quota ? (
<StatusBadge variant='neutral'>{t('Unlimited')}</StatusBadge>
) : (
<span className='font-medium tabular-nums'>
{formatQuota(apiKey.remain_quota)}
<span className='text-muted-foreground font-normal'>
{' / '}
{formatQuota(totalQuota)}
</span>
</span>
)}
</DataTableCardField>
)}
</div>
{detailsCount > 0 && (
<DataTableCardDetails count={detailsCount}>
{visibleColumnIds.has('status') && (
<DataTableCardField label={t('Status')} contentMode='full'>
{renderApiKeyCell(props.row, 'status')}
</DataTableCardField>
)}
{visibleColumnIds.has('group') && (
<DataTableCardField label={t('Group')} contentMode='full'>
{renderApiKeyCell(props.row, 'group')}
</DataTableCardField>
)}
{visibleColumnIds.has('model_limits') && (
<DataTableCardField label={t('Models')} span={2} contentMode='full'>
<ApiKeyModels apiKey={apiKey} />
</DataTableCardField>
)}
{visibleColumnIds.has('allow_ips') && (
<DataTableCardField
label={t('IP Restriction')}
span={2}
contentMode='full'
>
<ApiKeyIpRestrictions apiKey={apiKey} />
</DataTableCardField>
)}
{visibleColumnIds.has('created_time') && (
<DataTableCardField label={t('Created')} contentMode='full'>
{renderApiKeyCell(props.row, 'created_time')}
</DataTableCardField>
)}
{visibleColumnIds.has('accessed_time') && (
<DataTableCardField label={t('Last Used')} contentMode='full'>
{renderApiKeyCell(props.row, 'accessed_time')}
</DataTableCardField>
)}
{visibleColumnIds.has('expired_time') && (
<DataTableCardField
label={t('Expires')}
span={2}
contentMode='full'
>
{renderApiKeyCell(props.row, 'expired_time')}
</DataTableCardField>
)}
{visibleColumnIds.has('actions') && (
<DataTableCardField
label={t('Operations')}
span={2}
contentMode='full'
>
{renderApiKeyCell(props.row, 'actions')}
</DataTableCardField>
)}
</DataTableCardDetails>
)}
</>
)
}
@@ -20,8 +20,7 @@ import { Check, ChevronsUpDown } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import {
Command,
CommandEmpty,
@@ -29,7 +28,8 @@ import {
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
} from '@/components/design-system/command'
import { Badge } from '@/components/ui/badge'
import {
Popover,
PopoverContent,
@@ -139,7 +139,7 @@ export function ApiKeyGroupCombobox({
role='combobox'
aria-expanded={open}
disabled={disabled}
className='border-input bg-muted/40 hover:bg-muted/55 hover:text-foreground active:bg-background data-popup-open:border-ring data-popup-open:bg-background data-popup-open:ring-ring/20 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 data-popup-open:ring-[3px] sm:min-h-20 sm:gap-3 sm:px-4 sm:py-3'
className='border-input bg-muted/40 hover:bg-muted/55 hover:text-foreground active:bg-background data-popup-open:border-ring data-popup-open:bg-background data-popup-open:ring-ring/20 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 data-popup-open:ring-[3px] sm:h-auto sm:min-h-20 sm:gap-3 sm:px-4 sm:py-3'
/>
}
>
+12 -24
View File
@@ -22,8 +22,8 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { BadgeCell } from '@/components/data-table'
import { Button } from '@/components/design-system/button'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
@@ -99,7 +99,7 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
<Button
variant='ghost'
size='sm'
className='text-muted-foreground h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono text-xs hover:bg-transparent aria-expanded:bg-transparent'
className='text-muted-foreground max-w-full min-w-0 justify-start truncate px-0 font-mono text-xs hover:bg-transparent aria-expanded:bg-transparent'
/>
}
>
@@ -135,8 +135,8 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
render={
<Button
variant='ghost'
size='icon'
className='size-7 shrink-0'
size='icon-sm'
className='shrink-0'
onClick={handleCopy}
onFocus={() => {
if (!resolvedFullKey) void resolveRealKey(apiKey.id)
@@ -160,9 +160,7 @@ export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
const { t } = useTranslation()
if (!apiKey.model_limits_enabled || !apiKey.model_limits) {
return (
<StatusBadge label={t('Unlimited')} variant='neutral' copyable={false} />
)
return <StatusBadge variant='neutral'>{t('Unlimited')}</StatusBadge>
}
const models = apiKey.model_limits.split(',').filter(Boolean)
@@ -170,11 +168,9 @@ export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
return (
<Tooltip>
<TooltipTrigger render={<BadgeCell />}>
<StatusBadge
label={t('{{count}} model(s)', { count: models.length })}
variant='neutral'
copyable={false}
/>
<StatusBadge variant='neutral'>
{t('{{count}} model(s)', { count: models.length })}
</StatusBadge>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-xs'>
<div className='max-h-[200px] space-y-0.5 overflow-y-auto text-xs'>
@@ -194,13 +190,7 @@ export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
const allowIps = apiKey.allow_ips?.trim()
if (!allowIps) {
return (
<StatusBadge
label={t('No restriction')}
variant='neutral'
copyable={false}
/>
)
return <StatusBadge variant='neutral'>{t('No restriction')}</StatusBadge>
}
const ips = allowIps
@@ -211,11 +201,9 @@ export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
return (
<Tooltip>
<TooltipTrigger render={<BadgeCell />}>
<StatusBadge
label={t('{{count}} IP(s)', { count: ips.length })}
variant='neutral'
copyable={false}
/>
<StatusBadge variant='neutral'>
{t('{{count}} IP(s)', { count: ips.length })}
</StatusBadge>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-xs'>
<div className='max-h-[200px] space-y-0.5 overflow-y-auto text-xs'>
+72 -56
View File
@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import type { ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
@@ -30,7 +29,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { getUserGroups } from '@/lib/api'
import { useGroupRatios } from '@/hooks/use-group-ratios'
import { formatQuota, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
@@ -44,32 +43,13 @@ import {
import { DataTableRowActions } from './data-table-row-actions'
function getQuotaProgressColor(percentage: number): string {
if (percentage <= 10)
if (percentage <= 10) {
return '[&_[data-slot=progress-indicator]]:bg-destructive'
}
if (percentage <= 30) return '[&_[data-slot=progress-indicator]]:bg-warning'
return '[&_[data-slot=progress-indicator]]:bg-success'
}
function useGroupRatios(): Record<string, number> {
const { data } = useQuery({
queryKey: ['user-groups'],
queryFn: getUserGroups,
staleTime: 0,
select: (res) => {
if (!res.success || !res.data) return {}
const ratios: Record<string, number> = {}
for (const [group, info] of Object.entries(res.data)) {
if (typeof info.ratio === 'number') {
ratios[group] = info.ratio
}
}
return ratios
},
})
return data ?? {}
}
export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
const { t } = useTranslation()
const groupRatios = useGroupRatios()
@@ -96,6 +76,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
enableSorting: false,
enableHiding: false,
size: 40,
meta: { cardRole: 'hidden' },
},
{
accessorKey: 'name',
@@ -104,7 +85,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
<span className='font-medium'>{row.getValue('name')}</span>
),
size: 180,
meta: { mobileTitle: true },
meta: {
cardRole: 'title',
cardSpan: 2,
contentMode: 'wrap',
},
},
{
accessorKey: 'status',
@@ -113,16 +98,18 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
const statusConfig = API_KEY_STATUSES[row.getValue('status') as number]
if (!statusConfig) return null
return (
<StatusBadge
label={t(statusConfig.label)}
variant={statusConfig.variant}
copyable={false}
/>
<StatusBadge variant={statusConfig.variant}>
{t(statusConfig.label)}
</StatusBadge>
)
},
filterFn: (row, id, value) => value.includes(String(row.getValue(id))),
size: 120,
meta: { mobileBadge: true },
meta: {
cardRole: 'secondary',
cardOrder: 10,
contentMode: 'full',
},
},
{
id: 'key',
@@ -131,6 +118,12 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
cell: ({ row }) => <ApiKeyCell apiKey={row.original} />,
enableSorting: false,
size: 260,
meta: {
cardRole: 'primary',
cardOrder: 10,
cardSpan: 2,
contentMode: 'full',
},
},
{
id: 'quota',
@@ -139,13 +132,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
cell: ({ row }) => {
const apiKey = row.original
if (apiKey.unlimited_quota) {
return (
<StatusBadge
label={t('Unlimited')}
variant='neutral'
copyable={false}
/>
)
return <StatusBadge variant='neutral'>{t('Unlimited')}</StatusBadge>
}
const used = apiKey.used_quota
@@ -187,6 +174,12 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
)
},
size: 170,
meta: {
cardRole: 'primary',
cardOrder: 20,
cardSpan: 2,
contentMode: 'full',
},
},
{
accessorKey: 'group',
@@ -204,11 +197,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
>
<GroupBadge group='auto' />
{apiKey.cross_group_retry && (
<StatusBadge
label={t('Cross-group')}
variant='info'
copyable={false}
/>
<StatusBadge variant='info'>{t('Cross-group')}</StatusBadge>
)}
</TooltipTrigger>
<TooltipContent>
@@ -231,7 +220,12 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
)
},
size: 160,
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 20,
cardSpan: 2,
contentMode: 'full',
},
},
{
id: 'model_limits',
@@ -240,7 +234,12 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
cell: ({ row }) => <ModelLimitsCell apiKey={row.original} />,
enableSorting: false,
size: 160,
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 30,
cardSpan: 2,
contentMode: 'full',
},
},
{
id: 'allow_ips',
@@ -249,7 +248,12 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
cell: ({ row }) => <IpRestrictionsCell apiKey={row.original} />,
enableSorting: false,
size: 160,
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 40,
cardSpan: 2,
contentMode: 'full',
},
},
{
accessorKey: 'created_time',
@@ -260,7 +264,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
</span>
),
size: 180,
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 50,
contentMode: 'full',
},
},
{
accessorKey: 'accessed_time',
@@ -277,7 +285,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
)
},
size: 180,
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 60,
contentMode: 'full',
},
},
{
accessorKey: 'expired_time',
@@ -285,13 +297,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
cell: ({ row }) => {
const expiredTime = row.getValue('expired_time') as number
if (expiredTime === -1) {
return (
<StatusBadge
label={t('Never')}
variant='neutral'
copyable={false}
/>
)
return <StatusBadge variant='neutral'>{t('Never')}</StatusBadge>
}
const isExpired = expiredTime * 1000 < Date.now()
return (
@@ -306,13 +312,23 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
)
},
size: 180,
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 70,
contentMode: 'full',
},
},
{
id: 'actions',
header: () => t('Actions'),
cell: ({ row }) => <DataTableRowActions row={row} />,
meta: { pinned: 'right' as const },
meta: {
pinned: 'right' as const,
cardRole: 'secondary',
cardOrder: 80,
cardSpan: 2,
contentMode: 'full',
},
},
]
}
@@ -29,7 +29,7 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
} from '@/components/design-system/alert-dialog'
import { deleteApiKey } from '../api'
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
@@ -25,6 +25,8 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { DateTimePicker } from '@/components/datetime-picker'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
SideDrawerSection,
SideDrawerSectionHeader,
@@ -35,7 +37,6 @@ import {
sideDrawerSwitchItemClassName,
} from '@/components/drawer-layout'
import { MultiSelect } from '@/components/multi-select'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
@@ -50,7 +51,6 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Sheet,
SheetClose,
@@ -364,8 +364,6 @@ export function ApiKeysMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
className='px-2 text-xs sm:px-3 sm:text-sm'
onClick={() => handleSetExpiry(0, 0, 0)}
>
{t('Never')}
@@ -373,8 +371,6 @@ export function ApiKeysMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
className='px-2 text-xs sm:px-3 sm:text-sm'
onClick={() => handleSetExpiry(1, 0, 0)}
>
{t('1 Month')}
@@ -382,8 +378,6 @@ export function ApiKeysMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
className='px-2 text-xs sm:px-3 sm:text-sm'
onClick={() => handleSetExpiry(0, 1, 0)}
>
{t('1 Day')}
@@ -391,8 +385,6 @@ export function ApiKeysMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
className='px-2 text-xs sm:px-3 sm:text-sm'
onClick={() => handleSetExpiry(0, 0, 1)}
>
{t('1 Hour')}
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import { useApiKeys } from './api-keys-provider'
@@ -28,7 +28,7 @@ export function ApiKeysPrimaryButtons() {
const { setOpen } = useApiKeys()
return (
<div className='flex gap-2'>
<Button size='sm' onClick={() => setOpen('create')}>
<Button onClick={() => setOpen('create')}>
<Plus className='h-4 w-4' />
{t('Create API Key')}
</Button>
+26 -136
View File
@@ -18,8 +18,6 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import { type Table as TanstackTable } from '@tanstack/react-table'
import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -27,36 +25,24 @@ import {
DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE,
DataTablePage,
MobileCardList,
useDebouncedColumnFilter,
useDataTable,
} from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/design-system/input'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { formatQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
import { getApiKeys, searchApiKeys } from '../api'
import {
API_KEY_STATUS,
API_KEY_STATUS_OPTIONS,
API_KEY_STATUSES,
ERROR_MESSAGES,
} from '../constants'
import { type ApiKey } from '../types'
import { ApiKeyCell } from './api-keys-cells'
import type { ApiKey } from '../types'
import { ApiKeyCard } from './api-key-card'
import { useApiKeysColumns } from './api-keys-columns'
import { useApiKeys } from './api-keys-provider'
import { DataTableBulkActions } from './data-table-bulk-actions'
import { DataTableRowActions } from './data-table-row-actions'
const route = getRouteApi('/_authenticated/keys/')
const API_KEYS_COLUMN_VISIBILITY_STORAGE_KEY = 'api-keys:column-visibility'
@@ -65,122 +51,6 @@ function isDisabledApiKeyRow(apiKey: ApiKey) {
return apiKey.status !== API_KEY_STATUS.ENABLED
}
function ApiKeysMobileSkeleton() {
return (
<div className='divide-border overflow-hidden rounded-lg border'>
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className='space-y-2 border-b px-3 py-2.5 last:border-b-0'
>
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-32' />
<Skeleton className='h-5 w-16 rounded-md' />
</div>
<div className='flex items-center justify-between gap-3'>
<Skeleton className='h-7 w-44' />
<Skeleton className='h-8 w-16' />
</div>
<Skeleton className='h-3 w-28' />
</div>
))}
</div>
)
}
function ApiKeysMobileList({
table,
isLoading,
}: {
table: TanstackTable<ApiKey>
isLoading: boolean
}) {
const { t } = useTranslation()
const rows = table.getRowModel().rows
if (isLoading) return <ApiKeysMobileSkeleton />
if (!rows.length) {
return (
<div className='rounded-lg border p-8'>
<Empty className='border-none p-0'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Database className='size-6' />
</EmptyMedia>
<EmptyTitle>{t('No API Keys Found')}</EmptyTitle>
<EmptyDescription>
{t(
'No API keys available. Create your first API key to get started.'
)}
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
)
}
return (
<div className='divide-border overflow-hidden rounded-lg border'>
{rows.map((row) => {
const apiKey = row.original
const statusConfig = API_KEY_STATUSES[apiKey.status]
const total = apiKey.used_quota + apiKey.remain_quota
return (
<div
key={row.id}
className={cn(
'bg-card space-y-2.5 border-b px-3 py-2.5 last:border-b-0',
isDisabledApiKeyRow(apiKey) && DISABLED_ROW_MOBILE
)}
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<div className='truncate text-sm font-semibold'>
{apiKey.name}
</div>
<div className='text-muted-foreground text-xs'>
{t('API Key')}
</div>
</div>
{statusConfig && (
<StatusBadge
label={t(statusConfig.label)}
variant={statusConfig.variant}
copyable={false}
/>
)}
</div>
<div className='flex min-w-0 items-center justify-between gap-2'>
<div className='min-w-0 flex-1 [&_button:first-child]:max-w-full [&_button:first-child]:truncate [&_button:first-child]:px-0'>
<ApiKeyCell apiKey={apiKey} />
</div>
<DataTableRowActions row={row} />
</div>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Quota')}</span>
{apiKey.unlimited_quota ? (
<span className='font-medium'>{t('Unlimited')}</span>
) : (
<span className='font-medium tabular-nums'>
{formatQuota(apiKey.remain_quota)}
<span className='text-muted-foreground font-normal'>
{' / '}
{formatQuota(total)}
</span>
</span>
)}
</div>
</div>
)
})}
</div>
)
}
export function ApiKeysTable() {
const { t } = useTranslation()
const { refreshTrigger } = useApiKeys()
@@ -197,7 +67,11 @@ export function ApiKeysTable() {
} = useTableUrlState({
search: route.useSearch(),
navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: 20 },
pagination: {
defaultPage: 1,
defaultPageSize: 20,
pageSizeStorageKey: 'api-keys:page-size:v1',
},
globalFilter: { enabled: true, key: 'filter' },
columnFilters: [
{ columnId: 'status', searchKey: 'status', type: 'array' },
@@ -283,6 +157,7 @@ export function ApiKeysTable() {
<DataTablePage
table={table}
columns={columns}
tableLabel={t('API Keys')}
isLoading={isLoading}
isFetching={isFetching}
emptyTitle={t('No API Keys Found')}
@@ -293,6 +168,8 @@ export function ApiKeysTable() {
applyHeaderSize
toolbarProps={{
searchPlaceholder: t('Filter by name...'),
hasAdditionalFilters: Boolean(tokenFilterInput.trim()),
onReset: () => setTokenFilterInput(''),
additionalSearch: (
<Input
placeholder={t('Filter by API key...')}
@@ -311,7 +188,20 @@ export function ApiKeysTable() {
},
],
}}
mobile={<ApiKeysMobileList table={table} isLoading={isLoading} />}
mobile={
<MobileCardList
table={table}
isLoading={isLoading}
emptyTitle={t('No API Keys Found')}
emptyDescription={t(
'No API keys available. Create your first API key to get started.'
)}
renderCard={(row) => <ApiKeyCard row={row} />}
getRowClassName={(row) =>
isDisabledApiKeyRow(row.original) ? DISABLED_ROW_MOBILE : undefined
}
/>
}
getRowClassName={(row) =>
isDisabledApiKeyRow(row.original) ? DISABLED_ROW_DESKTOP : undefined
}
@@ -23,7 +23,7 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import {
Tooltip,
TooltipContent,
@@ -89,7 +89,6 @@ export function DataTableBulkActions<TData>({
<Button
variant='outline'
size='icon'
className='size-8'
onClick={handleBatchCopy}
disabled={isCopying}
aria-label={t('Copy selected keys')}
@@ -114,7 +113,6 @@ export function DataTableBulkActions<TData>({
variant='destructive'
size='icon'
onClick={() => setShowDeleteConfirm(true)}
className='size-8'
aria-label={t('Delete selected API keys')}
/>
}
@@ -33,7 +33,7 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import {
DropdownMenuItem,
DropdownMenuSeparator,
@@ -21,9 +21,9 @@ import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { ComboboxInput } from '@/components/design-system/combobox-input'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { ComboboxInput } from '@/components/ui/combobox-input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { getUserModels } from '@/lib/api'