🏗️ refactor(web): redesign console layout with fixed header, scrollable content, and pinned footer

Overhaul the authenticated console layout to match the OpenAI dashboard
pattern: header and page title bar stay fixed at the top, only the
content area scrolls, and table pagination is pinned to the bottom.

Layout architecture:
- Lock SidebarInset to full viewport height (h-svh) so all inner
  regions are controlled by flexbox instead of document scroll
- Convert Main from a generic div to a semantic <main> flex container
  with overflow-hidden, removing the legacy `fixed` prop and
  `data-layout` attribute
- Strip scroll-shadow logic and `fixed` prop from Header/AppHeader;
  the header is now naturally fixed as a shrink-0 flex child
- Restructure SectionPageLayout into three flex regions: a shrink-0
  title bar, a flex-1 overflow-auto content area, and a shrink-0
  footer portal target with empty:hidden
- Add min-h-0 to AnimatedOutlet wrappers to prevent flex overflow

Footer portal system:
- Introduce PageFooterProvider / PageFooterPortal (React Context +
  createPortal) so deeply nested table components can render their
  DataTablePagination into the fixed footer without prop drilling
- Migrate all 8 data tables (api-keys, channels, users, models,
  deployments, usage-logs, subscriptions, redemption-codes) to use
  PageFooterPortal for pagination

Page-level fixes:
- Profile: wrap content in a scrollable flex child with proper padding
- SystemSettings: remove overflow-auto from wrapper to avoid nested
  scrollbars (sub-pages manage their own scroll)
- Playground / Error pages: remove obsolete `fixed` props

API keys UX improvement:
- Replace inline key show/hide toggle with a Popover-based reveal,
  removing toggleKeyVisibility and keyVisibility state from the
  provider context

Cleanup:
- Remove dead CSS rule for body:has([data-layout='fixed'])
- Remove unused `fixed` prop from Header, AppHeader, and Main types
- Export PageFooterPortal from layout barrel file
This commit is contained in:
t0ng7u
2026-04-14 00:57:58 +08:00
parent a464490737
commit d2150469be
24 changed files with 800 additions and 792 deletions
@@ -28,10 +28,6 @@ import { TopNav } from './top-nav'
* <AppHeader showTopNav={false} showSearch={false} />
*
* @example
* // Fixed at top
* <AppHeader fixed />
*
* @example
* // Fully customize left and right content
* <AppHeader
* leftContent={<CustomLeft />}
@@ -57,11 +53,6 @@ type AppHeaderProps = {
* @default true
*/
showSearch?: boolean
/**
* Whether to fix at top
* @default false
*/
fixed?: boolean
/**
* Custom right content, overrides default right content if provided
*/
@@ -88,7 +79,6 @@ export function AppHeader({
showTopNav = true,
leftContent,
showSearch = true,
fixed = false,
rightContent,
showNotifications = true,
showConfigDrawer = true,
@@ -107,7 +97,7 @@ export function AppHeader({
return (
<>
<Header fixed={fixed}>
<Header>
{leftSection}
{rightContent ?? (
<div className='ms-auto flex items-center space-x-4'>
@@ -25,8 +25,8 @@ export function AuthenticatedLayout(props: AuthenticatedLayoutProps) {
<SidebarInset
className={cn(
'@container/content',
'has-[[data-layout=fixed]]:h-svh',
'peer-data-[variant=inset]:has-[[data-layout=fixed]]:h-[calc(100svh-(var(--spacing)*4))]'
'h-svh',
'peer-data-[variant=inset]:h-[calc(100svh-(var(--spacing)*4))]'
)}
>
{props.children ?? <AnimatedOutlet />}
@@ -1,53 +1,16 @@
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar'
type HeaderProps = React.HTMLAttributes<HTMLElement> & {
/**
* 是否固定在顶部
*/
fixed?: boolean
}
/**
* 基础 Header 组件
* 包含侧边栏触发器和分隔线
* - fixed=true 时会固定在顶部,并在滚动时添加阴影效果
*/
export function Header({ className, fixed, children, ...props }: HeaderProps) {
const [scrollOffset, setScrollOffset] = useState(0)
useEffect(() => {
const handleScroll = () => {
setScrollOffset(
document.body.scrollTop || document.documentElement.scrollTop
)
}
document.addEventListener('scroll', handleScroll, { passive: true })
return () => document.removeEventListener('scroll', handleScroll)
}, [])
const shouldShowShadow = scrollOffset > 10 && fixed
type HeaderProps = React.HTMLAttributes<HTMLElement>
export function Header({ className, children, ...props }: HeaderProps) {
return (
<header
className={cn(
'z-50 h-16',
fixed && 'header-fixed peer/header sticky top-0 w-[inherit]',
shouldShowShadow ? 'shadow' : 'shadow-none',
className
)}
className={cn('bg-background z-50 h-16 shrink-0 border-b', className)}
{...props}
>
<div
className={cn(
'relative flex h-full items-center gap-3 p-4 sm:gap-4',
shouldShowShadow &&
'after:bg-background/20 after:absolute after:inset-0 after:-z-10 after:backdrop-blur-lg'
)}
>
<div className='flex h-full items-center gap-3 p-4 sm:gap-4'>
<SidebarTrigger variant='outline' />
<Separator orientation='vertical' className='h-6' />
{children}
+2 -19
View File
@@ -1,31 +1,14 @@
import { cn } from '@/lib/utils'
type MainProps = React.HTMLAttributes<HTMLElement> & {
/**
* 是否使用固定布局(防止内容溢出)
*/
fixed?: boolean
/**
* 是否使用流式布局(不限制最大宽度)
* - 默认开启(true
*/
fluid?: boolean
}
/**
* Main 内容区域组件
* - fixed=true 时会使用 flexbox 布局并防止内容溢出
* - fluid=true 时不会限制最大宽度(默认)
*/
export function Main({ fixed, className, fluid = true, ...props }: MainProps) {
export function Main({ className, fluid = true, ...props }: MainProps) {
return (
<main
data-layout={fixed ? 'fixed' : 'auto'}
className={cn(
'px-4 py-6',
// 固定布局:使用 flex 并防止溢出
fixed && 'flex grow flex-col overflow-hidden',
// 非流式布局:在大屏幕上限制最大宽度
'flex min-h-0 flex-1 flex-col overflow-hidden',
!fluid &&
'@7xl/content:mx-auto @7xl/content:w-full @7xl/content:max-w-7xl',
className
@@ -0,0 +1,23 @@
import { createContext, useContext, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
const PageFooterContext = createContext<HTMLDivElement | null>(null)
type PageFooterProviderProps = {
container: HTMLDivElement | null
children: ReactNode
}
export function PageFooterProvider(props: PageFooterProviderProps) {
return (
<PageFooterContext.Provider value={props.container}>
{props.children}
</PageFooterContext.Provider>
)
}
export function PageFooterPortal(props: { children: ReactNode }) {
const container = useContext(PageFooterContext)
if (!container) return null
return createPortal(props.children, container)
}
@@ -1,11 +1,13 @@
import {
Children,
isValidElement,
useState,
type ReactElement,
type ReactNode,
} from 'react'
import { AppHeader } from './app-header'
import { Main } from './main'
import { PageFooterProvider } from './page-footer'
type SlotProps = { children?: ReactNode }
@@ -39,6 +41,10 @@ export type SectionPageLayoutProps = {
}
export function SectionPageLayout(props: SectionPageLayoutProps) {
const [footerContainer, setFooterContainer] = useState<HTMLDivElement | null>(
null
)
let title: ReactNode = null
let description: ReactNode = null
let actions: ReactNode = null
@@ -60,29 +66,35 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
})
return (
<>
<AppHeader fixed />
<PageFooterProvider container={footerContainer}>
<AppHeader />
<Main>
{breadcrumb != null && <div className='mb-3'>{breadcrumb}</div>}
<div className='mb-2 flex flex-wrap items-center justify-between space-y-2 gap-x-4'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>{title}</h2>
{description != null && (
<p className='text-muted-foreground'>{description}</p>
<div className='shrink-0 px-4 pt-6 pb-4'>
{breadcrumb != null && <div className='mb-3'>{breadcrumb}</div>}
<div className='flex flex-wrap items-center justify-between gap-x-4 gap-y-2'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>{title}</h2>
{description != null && (
<p className='text-muted-foreground'>{description}</p>
)}
</div>
{actions != null && (
<div className='flex flex-wrap items-center gap-x-4 gap-y-2'>
{actions}
</div>
)}
</div>
{actions != null && (
<div className='flex flex-wrap items-center gap-x-4 gap-y-2'>
{actions}
</div>
)}
</div>
<div className='-mx-4 flex-1 overflow-auto px-4 py-1 lg:flex-row lg:space-y-0 lg:space-x-12'>
{content}
</div>
<div className='min-h-0 flex-1 overflow-auto px-4 pb-4'>{content}</div>
<div
ref={setFooterContainer}
className='bg-background shrink-0 border-t px-4 py-3 empty:hidden'
/>
</Main>
</>
</PageFooterProvider>
)
}
+1
View File
@@ -13,6 +13,7 @@ export { HeaderLogo } from './components/header-logo'
export { NavLinkItem, NavLinkList } from './components/nav-link-item'
export { Header } from './components/header'
export { Main } from './components/main'
export { PageFooterPortal } from './components/page-footer'
export { NavGroup } from './components/nav-group'
export { SectionPageLayout } from './components/section-page-layout'
export { WorkspaceSwitcher } from './components/workspace-switcher'
+6 -2
View File
@@ -43,7 +43,11 @@ export function AnimatedOutlet() {
})
if (shouldReduce) {
return <Outlet />
return (
<div className='flex min-h-0 flex-1 flex-col'>
<Outlet />
</div>
)
}
return (
@@ -52,7 +56,7 @@ export function AnimatedOutlet() {
initial={MOTION_VARIANTS.pageEnter.initial}
animate={MOTION_VARIANTS.pageEnter.animate}
transition={MOTION_TRANSITION.fast}
className='flex flex-1 flex-col'
className='flex min-h-0 flex-1 flex-col'
>
<Outlet />
</motion.div>
+102 -110
View File
@@ -2,6 +2,7 @@ import { useState, useMemo, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import {
flexRender,
getCoreRowModel,
useReactTable,
getExpandedRowModel,
@@ -29,6 +30,7 @@ import {
MobileCardList,
} from '@/components/data-table'
import { DataTablePagination } from '@/components/data-table/pagination'
import { PageFooterPortal } from '@/components/layout'
import { getChannels, searchChannels, getGroups } from '../api'
import {
DEFAULT_PAGE_SIZE,
@@ -274,120 +276,110 @@ export function ChannelsTable() {
]
return (
<div className='space-y-4 max-sm:has-[div[role="toolbar"]]:mb-16'>
{/* Toolbar with Filters */}
<DataTableToolbar
table={table}
searchPlaceholder={t('Filter by name, ID, or key...')}
additionalSearch={
<Input
placeholder={t('Filter by model...')}
value={modelFilterInput}
onChange={(e) => setModelFilterInput(e.target.value)}
className='h-8 w-full sm:w-[150px] lg:w-[200px]'
/>
}
filters={[
{
columnId: 'status',
title: t('Status'),
options: [...CHANNEL_STATUS_OPTIONS],
singleSelect: true,
},
{
columnId: 'type',
title: t('Type'),
options: typeFilterOptions,
singleSelect: true,
},
{
columnId: 'group',
title: t('Group'),
options: groupFilterOptions,
singleSelect: true,
},
]}
/>
{isMobile ? (
<MobileCardList
<>
<div className='space-y-4'>
<DataTableToolbar
table={table}
isLoading={isLoading}
emptyTitle='No Channels Found'
emptyDescription='No channels available. Create your first channel to get started.'
searchPlaceholder={t('Filter by name, ID, or key...')}
additionalSearch={
<Input
placeholder={t('Filter by model...')}
value={modelFilterInput}
onChange={(e) => setModelFilterInput(e.target.value)}
className='h-8 w-full sm:w-[150px] lg:w-[200px]'
/>
}
filters={[
{
columnId: 'status',
title: t('Status'),
options: [...CHANNEL_STATUS_OPTIONS],
singleSelect: true,
},
{
columnId: 'type',
title: t('Type'),
options: typeFilterOptions,
singleSelect: true,
},
{
columnId: 'group',
title: t('Group'),
options: groupFilterOptions,
singleSelect: true,
},
]}
/>
) : (
<>
{/* Table */}
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: header.getSize() }}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton table={table} keyPrefix='channel-skeleton' />
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={columns.length}
title={t('No Channels Found')}
description={t(
'No channels available. Create your first channel to get started.'
)}
/>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
{isMobile ? (
<MobileCardList
table={table}
isLoading={isLoading}
emptyTitle='No Channels Found'
emptyDescription='No channels available. Create your first channel to get started.'
/>
) : (
<>
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: header.getSize() }}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton table={table} keyPrefix='channel-skeleton' />
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={columns.length}
title={t('No Channels Found')}
description={t(
'No channels available. Create your first channel to get started.'
)}
/>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Bulk Actions Floating Toolbar */}
<DataTableBulkActions table={table} />
</>
)}
{/* Pagination */}
<DataTablePagination table={table} />
</div>
<DataTableBulkActions table={table} />
</>
)}
</div>
<PageFooterPortal>
<DataTablePagination table={table} />
</PageFooterPortal>
</>
)
}
// Helper to render cell content
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function flexRender(content: unknown, context: any) {
if (typeof content === 'function') {
return content(context)
}
return content
}
+68 -51
View File
@@ -1,8 +1,13 @@
import { useCallback } from 'react'
import { Check, Eye, EyeOff, Copy, Loader2 } from 'lucide-react'
import { useState, useCallback } from 'react'
import { Check, Copy, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Tooltip,
TooltipContent,
@@ -16,70 +21,74 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
const { t } = useTranslation()
const {
resolveRealKey,
toggleKeyVisibility,
keyVisibility,
resolvedKeys,
loadingKeys,
copiedKeyId,
markKeyCopied,
} = useApiKeys()
const [popoverOpen, setPopoverOpen] = useState(false)
const isVisible = !!keyVisibility[apiKey.id]
const isLoading = !!loadingKeys[apiKey.id]
const resolvedFullKey = resolvedKeys[apiKey.id]
const isCopied = copiedKeyId === apiKey.id
const maskedKey = `sk-${apiKey.key}`
const displayedKey =
isVisible && resolvedFullKey
? resolvedFullKey
: `sk-${apiKey.key.slice(0, 4)}${'*'.repeat(16)}${apiKey.key.slice(-4)}`
const handleCopy = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation()
const realKey = resolvedFullKey || (await resolveRealKey(apiKey.id))
if (realKey) {
const success = await copyToClipboard(realKey)
if (success) {
markKeyCopied(apiKey.id)
}
const handlePopoverOpen = useCallback(
(open: boolean) => {
setPopoverOpen(open)
if (open && !resolvedFullKey) {
resolveRealKey(apiKey.id)
}
},
[resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied]
[resolvedFullKey, resolveRealKey, apiKey.id]
)
const handleCopy = useCallback(async () => {
const realKey = resolvedFullKey || (await resolveRealKey(apiKey.id))
if (realKey) {
const ok = await copyToClipboard(realKey)
if (ok) markKeyCopied(apiKey.id)
}
}, [resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied])
return (
<div className='flex items-center gap-0.5'>
<span
className={`max-w-[180px] truncate font-mono text-xs ${isVisible ? '' : 'text-muted-foreground'}`}
>
{displayedKey}
</span>
<Tooltip>
<TooltipTrigger asChild>
<div className='flex items-center'>
<Popover open={popoverOpen} onOpenChange={handlePopoverOpen}>
<PopoverTrigger asChild>
<Button
variant='ghost'
size='icon'
className='size-7 shrink-0'
disabled={isLoading}
onClick={(e) => {
e.stopPropagation()
toggleKeyVisibility(apiKey.id)
}}
size='sm'
className='text-muted-foreground h-7 font-mono text-xs'
>
{isLoading ? (
<Loader2 className='size-3.5 animate-spin' />
) : isVisible ? (
<EyeOff className='size-3.5' />
) : (
<Eye className='size-3.5' />
)}
{maskedKey}
{isLoading && <Loader2 className='ml-1 size-3 animate-spin' />}
</Button>
</TooltipTrigger>
<TooltipContent>
{isVisible ? t('Hide API key') : t('Reveal API key')}
</TooltipContent>
</Tooltip>
</PopoverTrigger>
<PopoverContent
className='w-auto max-w-[min(90vw,28rem)]'
align='start'
>
<div className='space-y-2'>
<p className='text-muted-foreground text-xs'>{t('Full API Key')}</p>
{isLoading ? (
<div className='flex items-center gap-2 py-2'>
<Loader2 className='size-3.5 animate-spin' />
<span className='text-muted-foreground text-xs'>
{t('Loading...')}
</span>
</div>
) : (
<input
readOnly
value={resolvedFullKey || maskedKey}
autoFocus
onFocus={(e) => e.target.select()}
className='bg-muted/50 w-full min-w-[280px] rounded-md border px-3 py-2 font-mono text-xs outline-none'
/>
)}
</div>
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -117,8 +126,12 @@ export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className='text-muted-foreground cursor-default text-xs font-medium'>
{t('{{count}} model(s)', { count: models.length })}
<span>
<StatusBadge
label={t('{{count}} model(s)', { count: models.length })}
variant='neutral'
copyable={false}
/>
</span>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-xs'>
@@ -156,8 +169,12 @@ export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className='text-muted-foreground cursor-default text-xs font-medium'>
{t('{{count}} IP(s)', { count: ips.length })}
<span>
<StatusBadge
label={t('{{count}} IP(s)', { count: ips.length })}
variant='neutral'
copyable={false}
/>
</span>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-xs'>
+46 -23
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
import { getUserGroups } from '@/lib/api'
import { formatQuota, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import { Checkbox } from '@/components/ui/checkbox'
import { Progress } from '@/components/ui/progress'
import {
@@ -24,6 +25,12 @@ import {
} from './api-keys-cells'
import { DataTableRowActions } from './data-table-row-actions'
function getQuotaProgressColor(percentage: number): string {
if (percentage <= 10) return '[&_[data-slot=progress-indicator]]:bg-rose-500'
if (percentage <= 30) return '[&_[data-slot=progress-indicator]]:bg-amber-500'
return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
}
function useGroupRatios(): Record<string, number> {
const isAdmin = useAuthStore((s) =>
Boolean(s.auth.user?.role && s.auth.user.role >= 10)
@@ -166,12 +173,17 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
<TooltipTrigger asChild>
<div className='w-[150px] space-y-1'>
<div className='flex justify-between text-xs'>
<span>{formatQuota(remaining)}</span>
<span className='text-muted-foreground'>
<span className='font-medium tabular-nums'>
{formatQuota(remaining)}
</span>
<span className='text-muted-foreground tabular-nums'>
{formatQuota(total)}
</span>
</div>
<Progress value={percentage} className='h-2' />
<Progress
value={percentage}
className={cn('h-1.5', getQuotaProgressColor(percentage))}
/>
</div>
</TooltipTrigger>
<TooltipContent>
@@ -180,14 +192,12 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
{t('Used:')} {formatQuota(used)}
</div>
<div>
{t('Remaining:')} {formatQuota(remaining)}
{t('Remaining:')} {formatQuota(remaining)} (
{percentage.toFixed(1)}%)
</div>
<div>
{t('Total:')} {formatQuota(total)}
</div>
<div>
{t('Percentage:')} {percentage.toFixed(1)}%
</div>
</div>
</TooltipContent>
</Tooltip>
@@ -204,17 +214,25 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
const apiKey = row.original
const group = row.getValue('group') as string
const ratio = group && group !== 'auto' ? groupRatios[group] : undefined
const ratioLabel = ratio != null ? `${ratio}x` : undefined
if (group === 'auto') {
return (
<Tooltip>
<TooltipTrigger asChild>
<StatusBadge
label={`Auto${apiKey.cross_group_retry ? ` (${t('Cross-group')})` : ''}`}
variant='neutral'
copyable={false}
/>
<span className='inline-flex items-center gap-1.5'>
<StatusBadge
label={t('Auto')}
variant='neutral'
copyable={false}
/>
{apiKey.cross_group_retry && (
<StatusBadge
label={t('Cross-group')}
variant='info'
copyable={false}
/>
)}
</span>
</TooltipTrigger>
<TooltipContent>
<span className='text-xs'>
@@ -230,13 +248,15 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
<div className='flex items-center gap-1.5'>
<StatusBadge
label={group || t('Default')}
variant='neutral'
autoColor={group || undefined}
copyable={false}
/>
{ratioLabel && (
<span className='text-muted-foreground rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums dark:bg-amber-900/30'>
{ratioLabel}
</span>
{ratio != null && (
<StatusBadge
label={`${ratio}x`}
variant='success'
copyable={false}
/>
)}
</div>
)
@@ -269,9 +289,9 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
<DataTableColumnHeader column={column} title={t('Created')} />
),
cell: ({ row }) => (
<div className='min-w-[140px] font-mono text-sm'>
<span className='text-muted-foreground font-mono text-xs tabular-nums'>
{formatTimestampToDate(row.getValue('created_time'))}
</div>
</span>
),
meta: { label: t('Created') },
},
@@ -293,11 +313,14 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
}
const isExpired = expiredTime * 1000 < Date.now()
return (
<div
className={`min-w-[140px] font-mono text-sm ${isExpired ? 'text-destructive' : ''}`}
<span
className={cn(
'font-mono text-xs tabular-nums',
isExpired ? 'text-destructive' : 'text-muted-foreground'
)}
>
{formatTimestampToDate(expiredTime)}
</div>
</span>
)
},
meta: { label: t('Expires') },
-22
View File
@@ -17,8 +17,6 @@ type ApiKeysContextType = {
setResolvedKey: React.Dispatch<React.SetStateAction<string>>
resolveRealKey: (id: number) => Promise<string | null>
resolveRealKeysBatch: (ids: number[]) => Promise<Record<number, string>>
toggleKeyVisibility: (id: number) => Promise<void>
keyVisibility: Record<number, boolean>
resolvedKeys: Record<number, string>
loadingKeys: Record<number, boolean>
copiedKeyId: number | null
@@ -35,9 +33,6 @@ export function ApiKeysProvider({ children }: { children: React.ReactNode }) {
const [resolvedKey, setResolvedKey] = useState('')
const [resolvedKeys, setResolvedKeys] = useState<Record<number, string>>({})
const [keyVisibility, setKeyVisibility] = useState<Record<number, boolean>>(
{}
)
const [loadingKeys, setLoadingKeys] = useState<Record<number, boolean>>({})
const pendingRequests = useRef<Record<number, Promise<string | null>>>({})
@@ -56,7 +51,6 @@ export function ApiKeysProvider({ children }: { children: React.ReactNode }) {
const triggerRefresh = useCallback(() => {
setRefreshTrigger((prev) => prev + 1)
setKeyVisibility({})
}, [])
const resolveRealKey = useCallback(
@@ -140,20 +134,6 @@ export function ApiKeysProvider({ children }: { children: React.ReactNode }) {
[resolvedKeys, t]
)
const toggleKeyVisibility = useCallback(
async (id: number) => {
if (keyVisibility[id]) {
setKeyVisibility((prev) => ({ ...prev, [id]: false }))
return
}
const key = await resolveRealKey(id)
if (key) {
setKeyVisibility((prev) => ({ ...prev, [id]: true }))
}
},
[keyVisibility, resolveRealKey]
)
return (
<ApiKeysContext
value={{
@@ -167,8 +147,6 @@ export function ApiKeysProvider({ children }: { children: React.ReactNode }) {
setResolvedKey,
resolveRealKey,
resolveRealKeysBatch,
toggleKeyVisibility,
keyVisibility,
resolvedKeys,
loadingKeys,
copiedKeyId,
+73 -68
View File
@@ -32,6 +32,7 @@ import {
TableEmpty,
MobileCardList,
} from '@/components/data-table'
import { PageFooterPortal } from '@/components/layout'
import { getApiKeys, searchApiKeys } from '../api'
import { API_KEY_STATUS_OPTIONS, ERROR_MESSAGES } from '../constants'
import { type ApiKey } from '../types'
@@ -156,33 +157,35 @@ export function ApiKeysTable() {
}, [pageCount, ensurePageInRange])
return (
<div className='space-y-4 max-sm:has-[div[role="toolbar"]]:mb-16'>
<DataTableToolbar
table={table}
searchPlaceholder={t('Filter by name or key...')}
filters={[
{
columnId: 'status',
title: t('Status'),
options: API_KEY_STATUS_OPTIONS,
},
]}
/>
{isMobile ? (
<MobileCardList
<>
<div className='space-y-4'>
<DataTableToolbar
table={table}
isLoading={isLoading}
emptyTitle='No API Keys Found'
emptyDescription='No API keys available. Create your first API key to get started.'
searchPlaceholder={t('Filter by name or key...')}
filters={[
{
columnId: 'status',
title: t('Status'),
options: API_KEY_STATUS_OPTIONS,
},
]}
/>
) : (
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
{isMobile ? (
<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.'
)}
/>
) : (
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
@@ -191,50 +194,52 @@ export function ApiKeysTable() {
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton table={table} keyPrefix='api-keys-skeleton' />
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={columns.length}
title={t('No API Keys Found')}
description={t(
'No API keys available. Create your first API key to get started.'
)}
/>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={
(row.original as ApiKey).status !== 1
? 'opacity-60'
: undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
<DataTablePagination table={table} />
{!isMobile && <DataTableBulkActions table={table} />}
</div>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton table={table} keyPrefix='api-keys-skeleton' />
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={columns.length}
title={t('No API Keys Found')}
description={t(
'No API keys available. Create your first API key to get started.'
)}
/>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={
(row.original as ApiKey).status !== 1
? 'opacity-60'
: undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
{!isMobile && <DataTableBulkActions table={table} />}
</div>
<PageFooterPortal>
<DataTablePagination table={table} />
</PageFooterPortal>
</>
)
}
+82 -73
View File
@@ -36,6 +36,7 @@ import {
TableSkeleton,
} from '@/components/data-table'
import { DataTablePagination } from '@/components/data-table/pagination'
import { PageFooterPortal } from '@/components/layout'
import { deleteDeployment, listDeployments, searchDeployments } from '../api'
import { getDeploymentStatusOptions } from '../constants'
import { deploymentsQueryKeys } from '../lib'
@@ -226,83 +227,91 @@ export function DeploymentsTable() {
}, [t])
return (
<div className='space-y-4 max-sm:has-[div[role="toolbar"]]:mb-16'>
{/* Toolbar with Filters */}
<DataTableToolbar
table={table}
searchPlaceholder={t('Search deployments...')}
filters={[
{
columnId: 'status',
title: t('Status'),
options: statusFilterOptions,
singleSelect: true,
},
]}
/>
{isMobile ? (
<MobileCardList
<>
<div className='space-y-4'>
<DataTableToolbar
table={table}
isLoading={isLoading}
emptyTitle={t('No Deployments Found')}
emptyDescription={t(
'No deployments available. Create one to get started.'
)}
searchPlaceholder={t('Search deployments...')}
filters={[
{
columnId: 'status',
title: t('Status'),
options: statusFilterOptions,
singleSelect: true,
},
]}
/>
) : (
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: header.getSize() }}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton table={table} keyPrefix='deployment-skeleton' />
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={table.getVisibleLeafColumns().length}
title={t('No Deployments Found')}
description={t(
'No deployments available. Create one to get started.'
)}
/>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
{isMobile ? (
<MobileCardList
table={table}
isLoading={isLoading}
emptyTitle={t('No Deployments Found')}
emptyDescription={t(
'No deployments available. Create one to get started.'
)}
/>
) : (
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: header.getSize() }}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton
table={table}
keyPrefix='deployment-skeleton'
/>
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={table.getVisibleLeafColumns().length}
title={t('No Deployments Found')}
description={t(
'No deployments available. Create one to get started.'
)}
/>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</div>
<DataTablePagination table={table as ReturnType<typeof useReactTable>} />
<PageFooterPortal>
<DataTablePagination
table={table as ReturnType<typeof useReactTable>}
/>
</PageFooterPortal>
<ViewLogsDialog
open={logsOpen}
@@ -380,6 +389,6 @@ export function DeploymentsTable() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</>
)
}
+98 -103
View File
@@ -2,6 +2,7 @@ import { useState, useMemo, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import {
flexRender,
getCoreRowModel,
useReactTable,
type SortingState,
@@ -25,6 +26,7 @@ import {
MobileCardList,
} from '@/components/data-table'
import { DataTablePagination } from '@/components/data-table/pagination'
import { PageFooterPortal } from '@/components/layout'
import { getModels, searchModels, getVendors } from '../api'
import {
DEFAULT_PAGE_SIZE,
@@ -213,113 +215,106 @@ export function ModelsTable() {
]
return (
<div className='space-y-4 max-sm:has-[div[role="toolbar"]]:mb-16'>
{/* Toolbar with Filters */}
<DataTableToolbar
table={table}
searchPlaceholder={t('Filter by model name...')}
filters={[
{
columnId: 'status',
title: t('Status'),
options: [...getModelStatusOptions(t)],
singleSelect: true,
},
{
columnId: 'vendor_id',
title: t('Vendor'),
options: vendorFilterOptions,
singleSelect: true,
},
{
columnId: 'sync_official',
title: t('Official Sync'),
options: [...getSyncStatusOptions(t)],
singleSelect: true,
},
]}
/>
{isMobile ? (
<MobileCardList
<>
<div className='space-y-4'>
<DataTableToolbar
table={table}
isLoading={isLoading}
emptyTitle={t('No Models Found')}
emptyDescription={t(
'No models available. Create your first model to get started.'
)}
searchPlaceholder={t('Filter by model name...')}
filters={[
{
columnId: 'status',
title: t('Status'),
options: [...getModelStatusOptions(t)],
singleSelect: true,
},
{
columnId: 'vendor_id',
title: t('Vendor'),
options: vendorFilterOptions,
singleSelect: true,
},
{
columnId: 'sync_official',
title: t('Official Sync'),
options: [...getSyncStatusOptions(t)],
singleSelect: true,
},
]}
/>
) : (
<>
{/* Table */}
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: header.getSize() }}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton table={table} keyPrefix='model-skeleton' />
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={columns.length}
title={t('No Models Found')}
description={t(
'No models available. Create your first model to get started.'
)}
/>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
{isMobile ? (
<MobileCardList
table={table}
isLoading={isLoading}
emptyTitle={t('No Models Found')}
emptyDescription={t(
'No models available. Create your first model to get started.'
)}
/>
) : (
<>
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: header.getSize() }}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton table={table} keyPrefix='model-skeleton' />
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={columns.length}
title={t('No Models Found')}
description={t(
'No models available. Create your first model to get started.'
)}
/>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Bulk Actions Floating Toolbar */}
<DataTableBulkActions table={table} />
</>
)}
{/* Pagination */}
<DataTablePagination table={table as ReturnType<typeof useReactTable>} />
</div>
<DataTableBulkActions table={table} />
</>
)}
</div>
<PageFooterPortal>
<DataTablePagination
table={table as ReturnType<typeof useReactTable>}
/>
</PageFooterPortal>
</>
)
}
// Helper to render cell content
function flexRender(content: unknown, context: unknown) {
if (typeof content === 'function') {
return content(context)
}
return content
}
+33 -34
View File
@@ -25,50 +25,49 @@ export function Profile() {
status?.turnstile_check && status?.turnstile_site_key
)
const turnstileSiteKey = status?.turnstile_site_key || ''
// Hide the sidebar customization card when the backend explicitly forbids
// it (e.g. root users). Default to visible to handle legacy sessions where
// the permissions field has not been populated yet.
const canConfigureSidebar = permissions?.sidebar_settings !== false
return (
<>
<AppHeader fixed />
<AppHeader />
<Main>
<CardStaggerContainer className='space-y-8'>
<CardStaggerItem>
<ProfileHeader profile={profile} loading={loading} />
</CardStaggerItem>
<div className='min-h-0 flex-1 overflow-auto px-4 py-6'>
<CardStaggerContainer className='space-y-8'>
<CardStaggerItem>
<ProfileHeader profile={profile} loading={loading} />
</CardStaggerItem>
<CardStaggerItem>
<AvailableModelsCard />
</CardStaggerItem>
<CardStaggerItem>
<AvailableModelsCard />
</CardStaggerItem>
<CardStaggerItem>
<div className='grid gap-6 lg:grid-cols-2 lg:items-start'>
<div className='space-y-6'>
<ProfileSecurityCard profile={profile} loading={loading} />
<PasskeyCard loading={loading} />
<TwoFACard loading={loading} />
</div>
<CardStaggerItem>
<div className='grid gap-6 lg:grid-cols-2 lg:items-start'>
<div className='space-y-6'>
<ProfileSecurityCard profile={profile} loading={loading} />
<PasskeyCard loading={loading} />
<TwoFACard loading={loading} />
</div>
<div className='space-y-6'>
{checkinEnabled && (
<CheckinCalendarCard
checkinEnabled={checkinEnabled}
turnstileEnabled={turnstileEnabled}
turnstileSiteKey={turnstileSiteKey}
<div className='space-y-6'>
{checkinEnabled && (
<CheckinCalendarCard
checkinEnabled={checkinEnabled}
turnstileEnabled={turnstileEnabled}
turnstileSiteKey={turnstileSiteKey}
/>
)}
<ProfileSettingsCard
profile={profile}
loading={loading}
onProfileUpdate={refreshProfile}
/>
)}
<ProfileSettingsCard
profile={profile}
loading={loading}
onProfileUpdate={refreshProfile}
/>
{canConfigureSidebar && <SidebarModulesCard />}
{canConfigureSidebar && <SidebarModulesCard />}
</div>
</div>
</div>
</CardStaggerItem>
</CardStaggerContainer>
</CardStaggerItem>
</CardStaggerContainer>
</div>
</Main>
</>
)
@@ -29,6 +29,7 @@ import {
TableSkeleton,
TableEmpty,
} from '@/components/data-table'
import { PageFooterPortal } from '@/components/layout'
import { getRedemptions, searchRedemptions } from '../api'
import { REDEMPTION_STATUS, getRedemptionStatusOptions } from '../constants'
import { isRedemptionExpired } from '../lib'
@@ -138,82 +139,86 @@ export function RedemptionsTable() {
)
return (
<div className='space-y-4 max-sm:has-[div[role="toolbar"]]:mb-16'>
<DataTableToolbar
table={table}
searchPlaceholder={t('Filter by name or ID...')}
filters={[
{
columnId: 'status',
title: t('Status'),
options: redemptionStatusOptions,
},
]}
/>
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton table={table} keyPrefix='redemptions-skeleton' />
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={columns.length}
title={t('No Redemption Codes Found')}
description={t(
'No redemption codes available. Create your first redemption code to get started.'
)}
/>
) : (
table.getRowModel().rows.map((row) => {
const redemption = row.original
const isDisabled =
redemption.status !== REDEMPTION_STATUS.ENABLED ||
isRedemptionExpired(
redemption.expired_time,
redemption.status
)
<>
<div className='space-y-4'>
<DataTableToolbar
table={table}
searchPlaceholder={t('Filter by name or ID...')}
filters={[
{
columnId: 'status',
title: t('Status'),
options: redemptionStatusOptions,
},
]}
/>
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton table={table} keyPrefix='redemptions-skeleton' />
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={columns.length}
title={t('No Redemption Codes Found')}
description={t(
'No redemption codes available. Create your first redemption code to get started.'
)}
/>
) : (
table.getRowModel().rows.map((row) => {
const redemption = row.original
const isDisabled =
redemption.status !== REDEMPTION_STATUS.ENABLED ||
isRedemptionExpired(
redemption.expired_time,
redemption.status
)
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={isDisabled ? 'opacity-50' : undefined}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
)
})
)}
</TableBody>
</Table>
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={isDisabled ? 'opacity-50' : undefined}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
<DataTableBulkActions table={table} />
</div>
<DataTablePagination table={table} />
<DataTableBulkActions table={table} />
</div>
<PageFooterPortal>
<DataTablePagination table={table} />
</PageFooterPortal>
</>
)
}
@@ -23,6 +23,7 @@ import {
TableSkeleton,
TableEmpty,
} from '@/components/data-table'
import { PageFooterPortal } from '@/components/layout'
import { getAdminPlans } from '../api'
import { useSubscriptionsColumns } from './subscriptions-columns'
import { useSubscriptions } from './subscriptions-provider'
@@ -57,7 +58,7 @@ export function SubscriptionsTable() {
})
return (
<div className='space-y-4'>
<>
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
@@ -104,7 +105,9 @@ export function SubscriptionsTable() {
</TableBody>
</Table>
</div>
<DataTablePagination table={table} />
</div>
<PageFooterPortal>
<DataTablePagination table={table} />
</PageFooterPortal>
</>
)
}
+4 -2
View File
@@ -5,10 +5,12 @@ import { AppHeader } from '@/components/layout/components/app-header'
export function SystemSettings() {
return (
<>
<AppHeader fixed />
<AppHeader />
<Main>
<Outlet />
<div className='min-h-0 flex-1'>
<Outlet />
</div>
</Main>
</>
)
+64 -59
View File
@@ -31,6 +31,7 @@ import {
TableEmpty,
MobileCardList,
} from '@/components/data-table'
import { PageFooterPortal } from '@/components/layout'
import { LOG_TYPE_FILTERS, DEFAULT_LOGS_DATA } from '../constants'
import { useColumnsByCategory } from '../lib/columns'
import { fetchLogsByCategory } from '../lib/utils'
@@ -161,66 +162,70 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
: []
return (
<div className='space-y-4 max-sm:has-[div[role="toolbar"]]:mb-16'>
<DataTableToolbar table={table} filters={filters} customSearch={null} />
{isMobile ? (
<MobileCardList
table={table}
isLoading={isLoadingData}
emptyTitle={t('No Logs Found')}
emptyDescription={t(
'No usage logs available. Logs will appear here once API calls are made.'
)}
/>
) : (
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoadingData ? (
<TableSkeleton table={table} keyPrefix='usage-log-skeleton' />
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={columns.length}
title={t('No Logs Found')}
description={t(
'No usage logs available. Logs will appear here once API calls are made.'
)}
/>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className='py-2'>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
<>
<div className='space-y-4'>
<DataTableToolbar table={table} filters={filters} customSearch={null} />
{isMobile ? (
<MobileCardList
table={table}
isLoading={isLoadingData}
emptyTitle={t('No Logs Found')}
emptyDescription={t(
'No usage logs available. Logs will appear here once API calls are made.'
)}
/>
) : (
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
<DataTablePagination table={table} />
</div>
))}
</TableHeader>
<TableBody>
{isLoadingData ? (
<TableSkeleton table={table} keyPrefix='usage-log-skeleton' />
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={columns.length}
title={t('No Logs Found')}
description={t(
'No usage logs available. Logs will appear here once API calls are made.'
)}
/>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className='py-2'>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</div>
<PageFooterPortal>
<DataTablePagination table={table} />
</PageFooterPortal>
</>
)
}
+73 -70
View File
@@ -30,6 +30,7 @@ import {
TableSkeleton,
TableEmpty,
} from '@/components/data-table'
import { PageFooterPortal } from '@/components/layout'
import { getUsers, searchUsers } from '../api'
import {
USER_STATUS,
@@ -155,30 +156,30 @@ export function UsersTable() {
}, [pageCount, ensurePageInRange])
return (
<div className='space-y-4 max-sm:has-[div[role="toolbar"]]:mb-16'>
<DataTableToolbar
table={table}
searchPlaceholder={t('Filter by username, name or email...')}
filters={[
{
columnId: 'status',
title: t('Status'),
options: getUserStatusOptions(t),
},
{
columnId: 'role',
title: t('Role'),
options: getUserRoleOptions(t),
},
]}
/>
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<>
<div className='space-y-4'>
<DataTableToolbar
table={table}
searchPlaceholder={t('Filter by username, name or email...')}
filters={[
{
columnId: 'status',
title: t('Status'),
options: getUserStatusOptions(t),
},
{
columnId: 'role',
title: t('Role'),
options: getUserRoleOptions(t),
},
]}
/>
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
@@ -187,53 +188,55 @@ export function UsersTable() {
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton table={table} keyPrefix='users-skeleton' />
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={columns.length}
title={t('No Users Found')}
description={t(
'No users available. Try adjusting your search or filters.'
)}
/>
) : (
table.getRowModel().rows.map((row) => {
const user = row.original
const isDeleted = isUserDeleted(user)
const isDisabled = user.status === USER_STATUS.DISABLED
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton table={table} keyPrefix='users-skeleton' />
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={columns.length}
title={t('No Users Found')}
description={t(
'No users available. Try adjusting your search or filters.'
)}
/>
) : (
table.getRowModel().rows.map((row) => {
const user = row.original
const isDeleted = isUserDeleted(user)
const isDisabled = user.status === USER_STATUS.DISABLED
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={
isDeleted || isDisabled ? 'opacity-50' : undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
)
})
)}
</TableBody>
</Table>
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={
isDeleted || isDisabled ? 'opacity-50' : undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
<DataTableBulkActions table={table} />
</div>
<DataTablePagination table={table} />
<DataTableBulkActions table={table} />
</div>
<PageFooterPortal>
<DataTablePagination table={table} />
</PageFooterPortal>
</>
)
}
+1 -1
View File
@@ -28,7 +28,7 @@ function RouteComponent() {
return (
<>
<Header fixed className='border-b'>
<Header>
<Search />
<div className='ms-auto flex items-center md:space-x-4'>
<ThemeSwitch />
+2 -2
View File
@@ -9,8 +9,8 @@ export const Route = createFileRoute('/_authenticated/playground/')({
function PlaygroundPage() {
return (
<>
<AppHeader fixed />
<Main fixed className='p-0'>
<AppHeader />
<Main className='p-0'>
<Playground />
</Main>
</>
-4
View File
@@ -18,10 +18,6 @@
@apply bg-background text-foreground has-[div[data-variant='inset']]:bg-sidebar min-h-svh w-full font-sans;
}
body:has([data-layout='fixed']) {
overflow: hidden;
}
/* Override Radix scroll locking for sticky headers */
body[data-scroll-locked] {
overflow: unset !important;