feat(ui): improve mobile responsive layouts

This commit is contained in:
CaIon
2026-04-30 19:53:02 +08:00
parent aa730395f1
commit d46df94f05
84 changed files with 1174 additions and 731 deletions
@@ -62,7 +62,13 @@ function GroupRatioBadge({ ratio }: { ratio: ApiKeyGroupOption['ratio'] }) {
if (!label) return null
return (
<Badge variant='outline' className={getRatioBadgeClassName(ratio)}>
<Badge
variant='outline'
className={cn(
'max-w-24 shrink-0 truncate text-[10px] sm:max-w-none sm:text-xs',
getRatioBadgeClassName(ratio)
)}
>
{label}
</Badge>
)
@@ -110,20 +116,22 @@ export function ApiKeyGroupCombobox({
role='combobox'
aria-expanded={open}
disabled={disabled}
className='border-input bg-muted/40 h-auto min-h-20 w-full justify-between gap-3 rounded-lg px-4 py-3 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]'
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-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-xs'>
<span className='text-muted-foreground block truncate text-[11px] sm:text-xs'>
{selectedOption.desc}
</span>
)}
</span>
<GroupRatioBadge ratio={selectedOption?.ratio} />
<span className='hidden sm:block'>
<GroupRatioBadge ratio={selectedOption?.ratio} />
</span>
</span>
<ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
</Button>
@@ -79,18 +79,18 @@ function ApiKeyFormSection(props: ApiKeyFormSectionProps) {
return (
<section className='bg-card rounded-lg border'>
<div className='flex items-center gap-3 border-b px-4 py-3'>
<div className='bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-lg border'>
<Icon className='size-5' />
<div className='flex items-center gap-2.5 border-b px-3 py-2.5 sm:gap-3 sm:px-4 sm:py-3'>
<div className='bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-lg border sm:size-10'>
<Icon className='size-4 sm:size-5' />
</div>
<div className='min-w-0'>
<h3 className='text-sm font-medium leading-none'>{props.title}</h3>
<p className='text-muted-foreground mt-1 text-xs'>
<p className='text-muted-foreground mt-0.5 text-xs sm:mt-1'>
{props.description}
</p>
</div>
</div>
<div className='space-y-4 p-4'>{props.children}</div>
<div className='space-y-3 p-3 sm:space-y-4 sm:p-4'>{props.children}</div>
</section>
)
}
@@ -254,13 +254,13 @@ export function ApiKeysMutateDrawer({
>
<SheetContent
side={side}
className='bg-background flex w-full gap-0 overflow-hidden p-0 sm:max-w-[620px]'
className='bg-background flex !h-dvh !w-screen max-w-none gap-0 overflow-hidden p-0 sm:!w-full sm:!max-w-[620px]'
>
<SheetHeader className='bg-background border-b px-5 py-4 text-start'>
<SheetTitle className='text-lg'>
<SheetHeader className='bg-background border-b px-4 py-3 text-start sm:px-5 sm:py-4'>
<SheetTitle className='text-base sm:text-lg'>
{isUpdate ? t('Update API Key') : t('Create API Key')}
</SheetTitle>
<SheetDescription>
<SheetDescription className='pr-6 text-xs sm:text-sm'>
{isUpdate
? t('Update the API key by providing necessary info.')
: t('Add a new API key by providing necessary info.')}{' '}
@@ -271,7 +271,7 @@ export function ApiKeysMutateDrawer({
<form
id='api-key-form'
onSubmit={form.handleSubmit(onSubmit)}
className='flex-1 space-y-4 overflow-y-auto px-4 py-4'
className='min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-3 py-3 sm:space-y-4 sm:px-4 sm:py-4'
>
<ApiKeyFormSection
title={t('Basic Information')}
@@ -319,12 +319,12 @@ export function ApiKeysMutateDrawer({
control={form.control}
name='cross_group_retry'
render={({ field }) => (
<FormItem className='flex min-h-20 flex-row items-center justify-between gap-4 rounded-lg border px-4 py-3'>
<FormItem className='flex min-h-16 flex-row items-center justify-between gap-3 rounded-lg border px-3 py-2.5 sm:min-h-20 sm:gap-4 sm:px-4 sm:py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t('Cross-group retry')}
</FormLabel>
<FormDescription className='text-xs'>
<FormDescription className='line-clamp-2 text-xs sm:line-clamp-none'>
{t(
'When enabled, if channels in the current group fail, it will try channels in the next group in order.'
)}
@@ -353,7 +353,7 @@ export function ApiKeysMutateDrawer({
value={field.value}
onChange={field.onChange}
placeholder={t('Never expires')}
className='min-w-0'
className='min-w-0 [&_input[type=time]]:w-24 sm:[&_input[type=time]]:w-32'
/>
</FormControl>
<div className='grid grid-cols-4 gap-2 sm:flex'>
@@ -361,7 +361,7 @@ export function ApiKeysMutateDrawer({
type='button'
variant='outline'
size='sm'
className='px-3'
className='px-2 text-xs sm:px-3 sm:text-sm'
onClick={() => handleSetExpiry(0, 0, 0)}
>
{t('Never')}
@@ -370,7 +370,7 @@ export function ApiKeysMutateDrawer({
type='button'
variant='outline'
size='sm'
className='px-3'
className='px-2 text-xs sm:px-3 sm:text-sm'
onClick={() => handleSetExpiry(1, 0, 0)}
>
{t('1 Month')}
@@ -379,7 +379,7 @@ export function ApiKeysMutateDrawer({
type='button'
variant='outline'
size='sm'
className='px-3'
className='px-2 text-xs sm:px-3 sm:text-sm'
onClick={() => handleSetExpiry(0, 1, 0)}
>
{t('1 Day')}
@@ -388,7 +388,7 @@ export function ApiKeysMutateDrawer({
type='button'
variant='outline'
size='sm'
className='px-3'
className='px-2 text-xs sm:px-3 sm:text-sm'
onClick={() => handleSetExpiry(0, 0, 1)}
>
{t('1 Hour')}
@@ -470,7 +470,7 @@ export function ApiKeysMutateDrawer({
control={form.control}
name='unlimited_quota'
render={({ field }) => (
<FormItem className='flex min-h-20 flex-row items-center justify-between gap-4 rounded-lg border px-4 py-3'>
<FormItem className='flex min-h-16 flex-row items-center justify-between gap-3 rounded-lg border px-3 py-2.5 sm:min-h-20 sm:gap-4 sm:px-4 sm:py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t('Unlimited Quota')}
@@ -495,10 +495,10 @@ export function ApiKeysMutateDrawer({
<CollapsibleTrigger asChild>
<button
type='button'
className='hover:bg-muted/50 flex w-full items-center gap-3 px-4 py-3 text-left transition-colors'
className='hover:bg-muted/50 flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors sm:gap-3 sm:px-4 sm:py-3'
>
<div className='bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-lg border'>
<Settings2 className='size-5' />
<div className='bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-lg border sm:size-10'>
<Settings2 className='size-4 sm:size-5' />
</div>
<div className='min-w-0 flex-1'>
<h3 className='text-sm font-medium leading-none'>
@@ -517,7 +517,7 @@ export function ApiKeysMutateDrawer({
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className='space-y-4 border-t p-4'>
<div className='space-y-3 border-t p-3 sm:space-y-4 sm:p-4'>
<FormField
control={form.control}
name='model_limits'
@@ -578,11 +578,18 @@ export function ApiKeysMutateDrawer({
</Collapsible>
</form>
</Form>
<SheetFooter className='bg-background gap-2 border-t px-5 py-4 sm:flex-row sm:justify-end'>
<SheetFooter className='bg-background grid grid-cols-2 gap-2 border-t px-3 py-3 sm:flex sm:flex-row sm:justify-end sm:px-5 sm:py-4'>
<SheetClose asChild>
<Button variant='outline'>{t('Close')}</Button>
<Button variant='outline' className='w-full sm:w-auto'>
{t('Close')}
</Button>
</SheetClose>
<Button form='api-key-form' type='submit' disabled={isSubmitting}>
<Button
form='api-key-form'
type='submit'
disabled={isSubmitting}
className='w-full sm:w-auto'
>
{isSubmitting ? t('Saving...') : t('Save changes')}
</Button>
</SheetFooter>
+138 -13
View File
@@ -16,7 +16,9 @@ import {
import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { formatQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
import { Database } from 'lucide-react'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import {
Table,
@@ -33,16 +35,31 @@ import {
DataTableToolbar,
TableSkeleton,
TableEmpty,
MobileCardList,
} from '@/components/data-table'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { PageFooterPortal } from '@/components/layout'
import { Skeleton } from '@/components/ui/skeleton'
import { StatusBadge } from '@/components/status-badge'
import { getApiKeys, searchApiKeys } from '../api'
import { API_KEY_STATUS, API_KEY_STATUS_OPTIONS, ERROR_MESSAGES } from '../constants'
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 { 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'
import { DataTableRowActions } from './data-table-row-actions'
const route = getRouteApi('/_authenticated/keys/')
@@ -50,6 +67,123 @@ 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-full' />
</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: ReturnType<typeof useReactTable<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-[11px]'>
{t('API Key')}
</div>
</div>
{statusConfig && (
<StatusBadge
label={t(statusConfig.label)}
variant={statusConfig.variant}
showDot={statusConfig.showDot}
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()
@@ -166,7 +300,7 @@ export function ApiKeysTable() {
return (
<>
<div className='space-y-4'>
<div className='space-y-3 sm:space-y-4'>
<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'>
@@ -184,18 +318,9 @@ export function ApiKeysTable() {
</div>
</div>
{isMobile ? (
<MobileCardList
<ApiKeysMobileList
table={table}
isLoading={isLoading}
emptyTitle={t('No API Keys Found')}
emptyDescription={t(
'No API keys available. Create your first API key to get started.'
)}
getRowClassName={(row) =>
isDisabledApiKeyRow(row.original)
? DISABLED_ROW_MOBILE
: undefined
}
/>
) : (
<div