♻️ refactor(web): consolidate design-system primitives and responsive data views
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user