🏗️ 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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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') },
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+33
-34
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
|
||||
Vendored
-4
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user