feat(ui): improve mobile responsive layouts
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user