refactor(web): unify table rendering components

- centralize static table headers, bodies, empty states, and shared class names behind the data-table package.
- migrate settings, pricing, channel, key, subscription, and model tables to the shared table APIs.
- remove data-table exports for low-level table primitives so feature code uses one supported abstraction.
This commit is contained in:
QuentinHsu
2026-06-09 21:08:13 +08:00
parent 04c0ae7aa8
commit 0863ddc3d9
30 changed files with 2044 additions and 2019 deletions
+3 -11
View File
@@ -21,21 +21,12 @@ export { DataTableColumnHeader } from './column-header'
export { DataTableViewOptions } from './view-options'
export { DataTableToolbar } from './toolbar'
export { DataTableBulkActions } from './bulk-actions'
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
export {
StaticDataTable,
StaticDataTableEmptyRow,
staticDataTableClassNames,
type StaticDataTableColumn,
} from './static-data-table'
export { staticDataTableClassNames } from './static-data-table-classnames'
export {
DataTableRow,
DataTableView,
@@ -47,6 +38,7 @@ export {
export { MobileCardList } from './mobile-card-list'
export { DataTablePage, type DataTablePageProps } from './data-table-page'
export { useDataTable } from './use-data-table'
export { useDebouncedColumnFilter } from './use-debounced-column-filter'
export const DISABLED_ROW_DESKTOP =
'bg-muted/85 hover:bg-muted [&>td:first-child]:border-l-muted-foreground/35 [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1'
@@ -0,0 +1,46 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export const staticDataTableClassNames = {
container: 'overflow-hidden rounded-md border',
sectionContainer: 'border-border/60 rounded-lg',
embeddedContainer: 'rounded-none border-0',
compactTable: 'text-sm',
compactHeaderRow: 'hover:bg-transparent',
mutedHeaderRow: 'bg-muted/30 hover:bg-muted/30',
compactHeaderCell:
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase',
compactHeaderCellRight:
'text-muted-foreground py-2 text-right text-[10px] font-medium tracking-wider uppercase',
compactCell: 'py-2.5',
compactTopCell: 'py-2.5 align-top',
compactTopNumericCell: 'py-2.5 text-right align-top font-mono',
compactMutedCell: 'text-muted-foreground py-2.5',
compactMutedCodeCell: 'text-muted-foreground py-2.5 font-mono',
compactNumericCell: 'py-2.5 text-right font-mono',
compactMutedNumericCell: 'text-muted-foreground py-2.5 text-right font-mono',
topCell: 'py-2 align-top',
topMutedCell: 'text-muted-foreground py-2 align-top',
codeCell: 'font-mono text-sm',
mutedCell: 'text-muted-foreground text-sm',
mutedCodeCell: 'text-muted-foreground font-mono text-sm',
topNumericCell: 'py-2 text-right font-mono',
mediumCell: 'font-medium',
actionHeaderCell: 'text-right',
actionCell: 'text-right',
} as const
+96 -35
View File
@@ -18,39 +18,27 @@ For commercial licensing, please contact support@quantumnous.com
*/
import * as React from 'react'
import { cn } from '@/lib/utils'
import { Table, TableCell, TableRow } from '@/components/ui/table'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { staticDataTableClassNames } from './static-data-table-classnames'
export const staticDataTableClassNames = {
container: 'overflow-hidden rounded-md border',
sectionContainer: 'border-border/60 rounded-lg',
embeddedContainer: 'rounded-none border-0',
compactTable: 'text-sm',
compactHeaderRow: 'hover:bg-transparent',
mutedHeaderRow: 'bg-muted/30 hover:bg-muted/30',
compactHeaderCell:
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase',
compactHeaderCellRight:
'text-muted-foreground py-2 text-right text-[10px] font-medium tracking-wider uppercase',
compactCell: 'py-2.5',
compactTopCell: 'py-2.5 align-top',
compactTopNumericCell: 'py-2.5 text-right align-top font-mono',
compactMutedCell: 'text-muted-foreground py-2.5',
compactMutedCodeCell: 'text-muted-foreground py-2.5 font-mono',
compactNumericCell: 'py-2.5 text-right font-mono',
compactMutedNumericCell: 'text-muted-foreground py-2.5 text-right font-mono',
topCell: 'py-2 align-top',
topMutedCell: 'text-muted-foreground py-2 align-top',
codeCell: 'font-mono text-sm',
mutedCell: 'text-muted-foreground text-sm',
mutedCodeCell: 'text-muted-foreground font-mono text-sm',
topNumericCell: 'py-2 text-right font-mono',
mediumCell: 'font-medium',
actionHeaderCell: 'text-right',
actionCell: 'text-right',
} as const
type StaticDataTableProps = {
children: React.ReactNode
type StaticDataTableProps<TData = unknown> = {
children?: React.ReactNode
columns?: StaticDataTableColumn<TData>[]
data?: TData[]
getRowKey?: (row: TData, index: number) => React.Key
getRowClassName?: (row: TData, index: number) => string | undefined
renderRow?: (row: TData, index: number) => React.ReactNode
empty?: boolean
emptyContent?: React.ReactNode
emptyClassName?: string
headerRowClassName?: string
className?: string
tableClassName?: string
containerProps?: Omit<React.ComponentProps<'div'>, 'className' | 'children'>
@@ -60,20 +48,93 @@ type StaticDataTableProps = {
>
}
export function StaticDataTable({
export type StaticDataTableColumn<TData = unknown> = {
id: string
header: React.ReactNode
className?: string
cellClassName?: string | ((row: TData, index: number) => string | undefined)
cell?: (row: TData, index: number) => React.ReactNode
}
export function StaticDataTable<TData = unknown>({
children,
columns,
data,
getRowKey,
getRowClassName,
renderRow,
empty,
emptyContent,
emptyClassName,
headerRowClassName,
className,
tableClassName,
containerProps,
tableProps,
}: StaticDataTableProps) {
}: StaticDataTableProps<TData>) {
const rows = data
? data.map((row, index) => {
const key = getRowKey?.(row, index) ?? index
if (renderRow) {
return <React.Fragment key={key}>{renderRow(row, index)}</React.Fragment>
}
return (
<TableRow key={key} className={getRowClassName?.(row, index)}>
{columns?.map((column) => {
const cellClassName =
typeof column.cellClassName === 'function'
? column.cellClassName(row, index)
: column.cellClassName
return (
<TableCell key={column.id} className={cellClassName}>
{column.cell?.(row, index)}
</TableCell>
)
})}
</TableRow>
)
})
: children
const isEmpty = empty ?? (data !== undefined && data.length === 0)
const content = columns ? (
<>
<TableHeader>
<TableRow className={headerRowClassName}>
{columns.map((column) => (
<TableHead key={column.id} className={column.className}>
{column.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{isEmpty ? (
<StaticDataTableEmptyRow
colSpan={columns.length}
className={emptyClassName}
>
{emptyContent}
</StaticDataTableEmptyRow>
) : (
rows
)}
</TableBody>
</>
) : (
rows
)
return (
<div
className={cn(staticDataTableClassNames.container, className)}
{...containerProps}
>
<Table className={tableClassName} {...tableProps}>
{children}
{content}
</Table>
</div>
)
@@ -0,0 +1,65 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import * as React from 'react'
import type { ColumnFiltersState, OnChangeFn } from '@tanstack/react-table'
import { useDebounce } from '@/hooks/use-debounce'
type UseDebouncedColumnFilterOptions = {
columnFilters: ColumnFiltersState
columnId: string
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>
delay?: number
}
export function useDebouncedColumnFilter({
columnFilters,
columnId,
onColumnFiltersChange,
delay = 500,
}: UseDebouncedColumnFilterOptions) {
const value =
(columnFilters.find((filter) => filter.id === columnId)?.value as
| string
| undefined) ?? ''
const [inputValue, setInputValue] = React.useState(value)
const debouncedValue = useDebounce(inputValue, delay)
React.useEffect(() => {
// Keep the input aligned when URL state changes outside the local field.
// eslint-disable-next-line react-hooks/set-state-in-effect
setInputValue(value)
}, [value])
React.useEffect(() => {
if (debouncedValue === value) return
onColumnFiltersChange((previous) => {
const filters = previous.filter((filter) => filter.id !== columnId)
return debouncedValue
? [...filters, { id: columnId, value: debouncedValue }]
: filters
})
}, [columnId, debouncedValue, onColumnFiltersChange, value])
return {
value,
inputValue,
setInputValue,
}
}
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useMemo, useEffect } from 'react'
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import {
@@ -24,7 +24,7 @@ import {
type SortingState,
type Row,
} from '@tanstack/react-table'
import { useDebounce, useMediaQuery } from '@/hooks'
import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon'
import { useTableUrlState } from '@/hooks/use-table-url-state'
@@ -33,6 +33,7 @@ import {
DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE,
DataTablePage,
useDebouncedColumnFilter,
useDataTable,
} from '@/components/data-table'
import { getChannels, searchChannels, getGroups } from '../api'
@@ -106,35 +107,21 @@ export function ChannelsTable() {
// Extract filters from column filters
const statusFilter =
(columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
const typeFilter =
(columnFilters.find((f) => f.id === 'type')?.value as string[]) || []
const typeFilter = useMemo(
() => (columnFilters.find((f) => f.id === 'type')?.value as string[]) || [],
[columnFilters]
)
const groupFilter =
(columnFilters.find((f) => f.id === 'group')?.value as string[]) || []
const modelFilterFromUrl =
(columnFilters.find((f) => f.id === 'model')?.value as string) || ''
// Local state for immediate input feedback
const [modelFilterInput, setModelFilterInput] = useState(modelFilterFromUrl)
const debouncedModelFilter = useDebounce(modelFilterInput, 500)
// Sync local input with URL when URL changes (e.g., from back/forward navigation)
useEffect(() => {
setModelFilterInput(modelFilterFromUrl)
}, [modelFilterFromUrl])
// Update URL when debounced value changes
useEffect(() => {
if (debouncedModelFilter !== modelFilterFromUrl) {
onColumnFiltersChange((prev) => {
const filtered = prev.filter((f) => f.id !== 'model')
return debouncedModelFilter
? [...filtered, { id: 'model', value: debouncedModelFilter }]
: filtered
})
}
}, [debouncedModelFilter, modelFilterFromUrl, onColumnFiltersChange])
const modelFilter = modelFilterFromUrl
const {
value: modelFilter,
inputValue: modelFilterInput,
setInputValue: setModelFilterInput,
} = useDebouncedColumnFilter({
columnFilters,
columnId: 'model',
onColumnFiltersChange,
})
// Determine whether to use search or regular list API
const shouldSearch = Boolean(globalFilter?.trim() || modelFilter.trim())
@@ -32,13 +32,6 @@ import {
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
@@ -361,44 +354,50 @@ export function MultiKeyManageDialog({
<StaticDataTable
className='rounded-none border-0'
tableClassName='min-w-[800px]'
>
<TableHeader>
<TableRow>
<TableHead className='w-20'>{t('Index')}</TableHead>
<TableHead className='w-32'>{t('Status')}</TableHead>
<TableHead className='min-w-[200px]'>
{t('Disabled Reason')}
</TableHead>
<TableHead className='w-44'>{t('Disabled Time')}</TableHead>
<TableHead className='w-44 text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.index}>
<TableCell className='font-mono text-sm'>
#{key.index + 1}
</TableCell>
<TableCell>{renderStatusBadge(key.status)}</TableCell>
<TableCell className='max-w-xs truncate text-sm'>
{key.reason || '-'}
</TableCell>
<TableCell className='text-muted-foreground text-sm'>
{formatKeyTimestamp(key.disabled_time)}
</TableCell>
<TableCell>
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
onAction={setConfirmAction}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</StaticDataTable>
data={keys}
getRowKey={(key) => key.index}
columns={[
{
id: 'index',
header: t('Index'),
className: 'w-20',
cellClassName: 'font-mono text-sm',
cell: (key) => `#${key.index + 1}`,
},
{
id: 'status',
header: t('Status'),
className: 'w-32',
cell: (key) => renderStatusBadge(key.status),
},
{
id: 'reason',
header: t('Disabled Reason'),
className: 'min-w-[200px]',
cellClassName: 'max-w-xs truncate text-sm',
cell: (key) => key.reason || '-',
},
{
id: 'disabled-time',
header: t('Disabled Time'),
className: 'w-44',
cellClassName: 'text-muted-foreground text-sm',
cell: (key) => formatKeyTimestamp(key.disabled_time),
},
{
id: 'actions',
header: t('Actions'),
className: 'w-44 text-right',
cell: (key) => (
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
onAction={setConfirmAction}
/>
),
},
]}
/>
)}
</div>
+10 -23
View File
@@ -16,11 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import { type Table as TanstackTable } from '@tanstack/react-table'
import { useDebounce } from '@/hooks'
import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -40,6 +38,7 @@ import {
DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE,
DataTablePage,
useDebouncedColumnFilter,
useDataTable,
} from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
@@ -203,27 +202,15 @@ export function ApiKeysTable() {
],
})
const tokenFilterFromUrl =
(columnFilters.find((f) => f.id === '_tokenSearch')?.value as string) || ''
const [tokenFilterInput, setTokenFilterInput] = useState(tokenFilterFromUrl)
const debouncedTokenFilter = useDebounce(tokenFilterInput, 500)
useEffect(() => {
setTokenFilterInput(tokenFilterFromUrl)
}, [tokenFilterFromUrl])
useEffect(() => {
if (debouncedTokenFilter !== tokenFilterFromUrl) {
onColumnFiltersChange((prev) => {
const filtered = prev.filter((f) => f.id !== '_tokenSearch')
return debouncedTokenFilter
? [...filtered, { id: '_tokenSearch', value: debouncedTokenFilter }]
: filtered
})
}
}, [debouncedTokenFilter, tokenFilterFromUrl, onColumnFiltersChange])
const tokenFilter = tokenFilterFromUrl
const {
value: tokenFilter,
inputValue: tokenFilterInput,
setInputValue: setTokenFilterInput,
} = useDebouncedColumnFilter({
columnFilters,
columnId: '_tokenSearch',
onColumnFiltersChange,
})
const shouldSearch = Boolean(globalFilter?.trim() || tokenFilter.trim())
// Fetch data with React Query
@@ -47,13 +47,6 @@ import {
EmptyTitle,
} from '@/components/ui/empty'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
@@ -344,46 +337,53 @@ export function PrefillGroupManagementDialog({
))}
</div>
) : (
<StaticDataTable tableClassName='min-w-[680px]'>
<TableHeader>
<TableRow>
<TableHead>{t('Group')}</TableHead>
<TableHead>{t('Type')}</TableHead>
<TableHead className='min-w-[240px]'>{t('Items')}</TableHead>
<TableHead className='w-[120px] text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{normalizedGroups.map(({ group, meta, parsedItems }) => (
<TableRow key={group.id}>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-col gap-1'>
<div className='flex flex-wrap items-center gap-2'>
<span className='font-medium'>{group.name}</span>
<TableId value={group.id} />
</div>
{group.description ? (
<p className='text-muted-foreground text-xs'>
{group.description}
</p>
) : (
<p className='text-muted-foreground text-xs italic'>
No description provided
</p>
)}
<StaticDataTable
tableClassName='min-w-[680px]'
data={normalizedGroups}
getRowKey={({ group }) => group.id}
columns={[
{
id: 'group',
header: t('Group'),
cellClassName: 'align-top whitespace-normal',
cell: ({ group }) => (
<div className='flex flex-col gap-1'>
<div className='flex flex-wrap items-center gap-2'>
<span className='font-medium'>{group.name}</span>
<TableId value={group.id} />
</div>
</TableCell>
<TableCell className='align-top'>
<StatusBadge
label={meta.label}
variant={meta.badge}
size='sm'
copyable={false}
/>
</TableCell>
<TableCell className='align-top whitespace-normal'>
{group.description ? (
<p className='text-muted-foreground text-xs'>
{group.description}
</p>
) : (
<p className='text-muted-foreground text-xs italic'>
No description provided
</p>
)}
</div>
),
},
{
id: 'type',
header: t('Type'),
cellClassName: 'align-top',
cell: ({ meta }) => (
<StatusBadge
label={meta.label}
variant={meta.badge}
size='sm'
copyable={false}
/>
),
},
{
id: 'items',
header: t('Items'),
className: 'min-w-[240px]',
cellClassName: 'align-top whitespace-normal',
cell: ({ group, parsedItems }) => (
<>
<div className='flex flex-wrap gap-2'>
{parsedItems.length > 0 ? (
<>
@@ -416,32 +416,38 @@ export function PrefillGroupManagementDialog({
{parsedItems.length} item
{parsedItems.length === 1 ? '' : 's'}
</div>
</TableCell>
<TableCell className='align-top'>
<div className='flex justify-end gap-2'>
<Button
size='icon'
variant='outline'
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>Edit group</span>
</Button>
<Button
size='icon'
variant='ghost'
className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>Delete group</span>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</StaticDataTable>
</>
),
},
{
id: 'actions',
header: t('Actions'),
className: 'w-[120px] text-right',
cellClassName: 'align-top',
cell: ({ group }) => (
<div className='flex justify-end gap-2'>
<Button
size='icon'
variant='outline'
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>Edit group</span>
</Button>
<Button
size='icon'
variant='ghost'
className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>Delete group</span>
</Button>
</div>
),
},
]}
/>
)}
</div>
</Dialog>
@@ -22,13 +22,6 @@ import { useTranslation } from 'react-i18next'
import { useSystemConfigStore } from '@/stores/system-config-store'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import {
BILLING_PRICING_VARS,
@@ -310,37 +303,34 @@ export function DynamicPricingBreakdown({
<StaticDataTable
className='hidden rounded-none border-0 sm:block'
tableClassName='text-sm'
>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='text-muted-foreground py-2 font-medium'>
{t('Tier')}
</TableHead>
{visiblePriceFields.map((v) => (
<TableHead
key={v.field}
className='text-muted-foreground py-2 text-right font-medium'
>
{t(v.shortLabel)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{tiers.map((tier, i) => {
const condSummary = formatConditionSummary(tier.conditions, t)
const isMatched =
normalizedMatchedTierLabel !== '' &&
normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
return (
<TableRow
key={`tier-${i}`}
className={cn(
isMatched &&
'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10'
)}
>
<TableCell className='py-2.5 align-top'>
headerRowClassName='hover:bg-transparent'
data={tiers}
getRowKey={(_tier, index) => `tier-${index}`}
getRowClassName={(tier) => {
const isMatched =
normalizedMatchedTierLabel !== '' &&
normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
return cn(
isMatched &&
'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10'
)
}}
columns={[
{
id: 'tier',
header: t('Tier'),
className: 'text-muted-foreground py-2 font-medium',
cellClassName: 'py-2.5 align-top',
cell: (tier) => {
const condSummary = formatConditionSummary(
tier.conditions,
t
)
const isMatched =
normalizedMatchedTierLabel !== '' &&
normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
return (
<>
<div className='flex flex-wrap items-center gap-1.5'>
<Badge
variant='secondary'
@@ -362,31 +352,30 @@ export function DynamicPricingBreakdown({
{condSummary}
</div>
)}
</TableCell>
{visiblePriceFields.map((v) => {
const value = Number(
tier[v.field as string as keyof ParsedTier] || 0
)
return (
<TableCell
key={v.field}
className='py-2.5 text-right align-top font-mono'
>
{value > 0 ? (
<span className='font-semibold'>
{`${symbol}${(value * rate).toFixed(4)}`}
</span>
) : (
'-'
)}
</TableCell>
)
})}
</TableRow>
)
})}
</TableBody>
</StaticDataTable>
</>
)
},
},
...visiblePriceFields.map((v, index) => ({
id: v.field ?? `price-${index}`,
header: t(v.shortLabel),
className: 'text-muted-foreground py-2 text-right font-medium',
cellClassName: 'py-2.5 text-right align-top font-mono',
cell: (tier: ParsedTier) => {
const value = Number(
tier[v.field as string as keyof ParsedTier] || 0
)
return value > 0 ? (
<span className='font-semibold'>
{`${symbol}${(value * rate).toFixed(4)}`}
</span>
) : (
'-'
)
},
})),
]}
/>
</div>
)}
@@ -37,13 +37,6 @@ import {
CodeBlock,
CodeBlockCopyButton,
} from '@/components/ai-elements/code-block'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import {
StaticDataTable,
staticDataTableClassNames as tableStyles,
@@ -573,51 +566,62 @@ function SupportedParametersSection(props: { model: PricingModel }) {
return (
<section>
<SectionTitle icon={Sigma}>{t('Supported parameters')}</SectionTitle>
<StaticDataTable className={tableStyles.sectionContainer}>
<TableHeader>
<TableRow className={tableStyles.mutedHeaderRow}>
<TableHead className='h-9 w-44'>{t('Parameter')}</TableHead>
<TableHead className='h-9 w-24'>{t('Type')}</TableHead>
<TableHead className='h-9 w-32'>{t('Default / range')}</TableHead>
<TableHead className='h-9'>{t('Description')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{params.map((p) => (
<TableRow key={p.name} className='hover:bg-muted/20'>
<TableCell className={tableStyles.topCell}>
<div className='flex items-center gap-1.5'>
<code className='font-mono text-sm font-medium'>
{p.name}
</code>
{p.required && (
<Badge
variant='outline'
className='h-6 border-rose-500/40 px-2 text-sm text-rose-600 dark:text-rose-400'
>
{t('required')}
</Badge>
)}
</div>
</TableCell>
<TableCell className={tableStyles.topCell}>
<Badge
variant='secondary'
className='h-7 rounded-full px-2.5 font-mono text-sm font-normal'
>
{p.type}
</Badge>
</TableCell>
<TableCell className={tableStyles.topCell}>
<ParamRangeCell param={p} />
</TableCell>
<TableCell className={tableStyles.topMutedCell}>
{t(p.descriptionKey)}
</TableCell>
</TableRow>
))}
</TableBody>
</StaticDataTable>
<StaticDataTable
className={tableStyles.sectionContainer}
headerRowClassName={tableStyles.mutedHeaderRow}
data={params}
getRowKey={(param) => param.name}
getRowClassName={() => 'hover:bg-muted/20'}
columns={[
{
id: 'parameter',
header: t('Parameter'),
className: 'h-9 w-44',
cellClassName: tableStyles.topCell,
cell: (p) => (
<div className='flex items-center gap-1.5'>
<code className='font-mono text-sm font-medium'>{p.name}</code>
{p.required && (
<Badge
variant='outline'
className='h-6 border-rose-500/40 px-2 text-sm text-rose-600 dark:text-rose-400'
>
{t('required')}
</Badge>
)}
</div>
),
},
{
id: 'type',
header: t('Type'),
className: 'h-9 w-24',
cellClassName: tableStyles.topCell,
cell: (p) => (
<Badge
variant='secondary'
className='h-7 rounded-full px-2.5 font-mono text-sm font-normal'
>
{p.type}
</Badge>
),
},
{
id: 'range',
header: t('Default / range'),
className: 'h-9 w-32',
cellClassName: tableStyles.topCell,
cell: (p) => <ParamRangeCell param={p} />,
},
{
id: 'description',
header: t('Description'),
className: 'h-9',
cellClassName: tableStyles.topMutedCell,
cell: (p) => t(p.descriptionKey),
},
]}
/>
</section>
)
}
@@ -672,32 +676,43 @@ function RateLimitsSection(props: { model: PricingModel }) {
return (
<section>
<SectionTitle icon={Gauge}>{t('Rate limits')}</SectionTitle>
<StaticDataTable className={tableStyles.sectionContainer}>
<TableHeader>
<TableRow className={tableStyles.mutedHeaderRow}>
<TableHead className='h-9'>{t('Group')}</TableHead>
<TableHead className='h-9 text-right'>RPM</TableHead>
<TableHead className='h-9 text-right'>TPM</TableHead>
<TableHead className='h-9 text-right'>RPD</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{limits.map((l) => (
<TableRow key={l.group} className='hover:bg-muted/20'>
<TableCell className='py-2 font-mono'>{l.group}</TableCell>
<TableCell className={tableStyles.topNumericCell}>
{formatRateLimit(l.rpm)}
</TableCell>
<TableCell className={tableStyles.topNumericCell}>
{formatRateLimit(l.tpm)}
</TableCell>
<TableCell className={tableStyles.topNumericCell}>
{formatRateLimit(l.rpd)}
</TableCell>
</TableRow>
))}
</TableBody>
</StaticDataTable>
<StaticDataTable
className={tableStyles.sectionContainer}
headerRowClassName={tableStyles.mutedHeaderRow}
data={limits}
getRowKey={(limit) => limit.group}
getRowClassName={() => 'hover:bg-muted/20'}
columns={[
{
id: 'group',
header: t('Group'),
className: 'h-9',
cellClassName: 'py-2 font-mono',
cell: (limit) => limit.group,
},
{
id: 'rpm',
header: 'RPM',
className: 'h-9 text-right',
cellClassName: tableStyles.topNumericCell,
cell: (limit) => formatRateLimit(limit.rpm),
},
{
id: 'tpm',
header: 'TPM',
className: 'h-9 text-right',
cellClassName: tableStyles.topNumericCell,
cell: (limit) => formatRateLimit(limit.tpm),
},
{
id: 'rpd',
header: 'RPD',
className: 'h-9 text-right',
cellClassName: tableStyles.topNumericCell,
cell: (limit) => formatRateLimit(limit.rpd),
},
]}
/>
<p className='text-muted-foreground mt-2 text-[11px] leading-relaxed'>
{t(
'RPM = requests per minute, TPM = tokens per minute, RPD = requests per day. Limits apply per token group.'
@@ -25,13 +25,6 @@ import {
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import {
StaticDataTable,
staticDataTableClassNames as tableStyles,
@@ -165,72 +158,70 @@ export function ModelDetailsApps(props: { model: PricingModel }) {
</div>
</div>
<StaticDataTable className='rounded-lg' tableClassName='text-sm'>
<TableHeader>
<TableRow className={tableStyles.compactHeaderRow}>
<TableHead className={cn(tableStyles.compactHeaderCell, 'w-12')}>
#
</TableHead>
<TableHead className={tableStyles.compactHeaderCell}>
{t('App')}
</TableHead>
<TableHead
className={cn(
tableStyles.compactHeaderCell,
'hidden md:table-cell'
)}
>
{t('Category')}
</TableHead>
<TableHead className={tableStyles.compactHeaderCellRight}>
{t('Monthly tokens')}
</TableHead>
<TableHead className={tableStyles.compactHeaderCellRight}>
{t('30d change')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apps.map((app) => (
<TableRow key={`${app.rank}-${app.name}`}>
<TableCell className={tableStyles.compactCell}>
<RankBadge rank={app.rank} />
</TableCell>
<TableCell className={tableStyles.compactCell}>
<div className='flex items-center gap-3'>
<span className='bg-muted text-muted-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md font-bold'>
{app.initial}
</span>
<div className='min-w-0'>
<div className='text-sm font-medium'>
<AppLink app={app} />
</div>
<p className='text-muted-foreground line-clamp-1 text-sm'>
{app.description}
</p>
<StaticDataTable
className='rounded-lg'
tableClassName='text-sm'
headerRowClassName={tableStyles.compactHeaderRow}
data={apps}
getRowKey={(app) => `${app.rank}-${app.name}`}
columns={[
{
id: 'rank',
header: '#',
className: cn(tableStyles.compactHeaderCell, 'w-12'),
cellClassName: tableStyles.compactCell,
cell: (app) => <RankBadge rank={app.rank} />,
},
{
id: 'app',
header: t('App'),
className: tableStyles.compactHeaderCell,
cellClassName: tableStyles.compactCell,
cell: (app) => (
<div className='flex items-center gap-3'>
<span className='bg-muted text-muted-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md font-bold'>
{app.initial}
</span>
<div className='min-w-0'>
<div className='text-sm font-medium'>
<AppLink app={app} />
</div>
<p className='text-muted-foreground line-clamp-1 text-sm'>
{app.description}
</p>
</div>
</TableCell>
<TableCell
className={cn(
tableStyles.compactMutedCell,
'hidden md:table-cell'
)}
>
{app.category}
</TableCell>
<TableCell
className={cn(tableStyles.compactNumericCell, 'tabular-nums')}
>
{formatTokenVolume(app.monthly_tokens)}
</TableCell>
<TableCell className={cn(tableStyles.compactCell, 'text-right')}>
<GrowthChip value={app.growth_pct} />
</TableCell>
</TableRow>
))}
</TableBody>
</StaticDataTable>
</div>
),
},
{
id: 'category',
header: t('Category'),
className: cn(
tableStyles.compactHeaderCell,
'hidden md:table-cell'
),
cellClassName: cn(
tableStyles.compactMutedCell,
'hidden md:table-cell'
),
cell: (app) => app.category,
},
{
id: 'monthly-tokens',
header: t('Monthly tokens'),
className: tableStyles.compactHeaderCellRight,
cellClassName: cn(tableStyles.compactNumericCell, 'tabular-nums'),
cell: (app) => formatTokenVolume(app.monthly_tokens),
},
{
id: 'growth',
header: t('30d change'),
className: tableStyles.compactHeaderCellRight,
cellClassName: cn(tableStyles.compactCell, 'text-right'),
cell: (app) => <GrowthChip value={app.growth_pct} />,
},
]}
/>
<p className='text-muted-foreground/60 text-[11px] leading-relaxed'>
{t(
@@ -30,13 +30,6 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import type { Modality } from '../types'
@@ -103,77 +96,65 @@ export function ModalitiesMatrix(props: {
const inputSet = new Set(props.input)
const outputSet = new Set(props.output)
const renderRow = (label: string, set: Set<Modality>) => (
<TableRow>
<TableHead
scope='row'
className='text-muted-foreground bg-muted/30 px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
>
{label}
</TableHead>
{ALL_MODALITIES.map((modality) => {
const enabled = set.has(modality)
const Icon = MODALITY_META[modality].icon
return (
<TableCell
key={modality}
className={cn(
return (
<StaticDataTable
className='rounded-lg'
tableClassName='text-sm'
headerRowClassName='bg-muted/40'
data={[
{ label: t('Input'), set: inputSet },
{ label: t('Output'), set: outputSet },
]}
getRowKey={(row) => row.label}
columns={[
{
id: 'modality',
header: t('Modality'),
className:
'text-muted-foreground px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase',
cellClassName:
'text-muted-foreground bg-muted/30 px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase',
cell: (row) => row.label,
},
...ALL_MODALITIES.map((modality) => ({
id: modality,
header: t(MODALITY_META[modality].labelKey),
className:
'text-muted-foreground border-l px-3 py-2 text-center text-[11px] font-medium tracking-wider uppercase',
cellClassName: (row: { label: string; set: Set<Modality> }) =>
cn(
'border-l px-3 py-2 text-center',
enabled
row.set.has(modality)
? 'bg-emerald-50/40 dark:bg-emerald-500/10'
: 'bg-background'
)}
>
<span
className={cn(
'inline-flex items-center justify-center',
enabled
? 'text-emerald-700 dark:text-emerald-300'
: 'text-muted-foreground/40'
)}
aria-label={
enabled
? t('{{modality}} supported', {
modality: t(MODALITY_META[modality].labelKey),
})
: t('{{modality}} not supported', {
modality: t(MODALITY_META[modality].labelKey),
})
}
>
<Icon className='size-4' />
</span>
</TableCell>
)
})}
</TableRow>
)
return (
<StaticDataTable className='rounded-lg' tableClassName='text-sm'>
<TableHeader>
<TableRow className='bg-muted/40'>
<TableHead
scope='col'
className='text-muted-foreground px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
>
{t('Modality')}
</TableHead>
{ALL_MODALITIES.map((modality) => (
<TableHead
key={modality}
scope='col'
className='text-muted-foreground border-l px-3 py-2 text-center text-[11px] font-medium tracking-wider uppercase'
>
{t(MODALITY_META[modality].labelKey)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{renderRow(t('Input'), inputSet)}
{renderRow(t('Output'), outputSet)}
</TableBody>
</StaticDataTable>
),
cell: (row: { label: string; set: Set<Modality> }) => {
const enabled = row.set.has(modality)
const Icon = MODALITY_META[modality].icon
return (
<span
className={cn(
'inline-flex items-center justify-center',
enabled
? 'text-emerald-700 dark:text-emerald-300'
: 'text-muted-foreground/40'
)}
aria-label={
enabled
? t('{{modality}} supported', {
modality: t(MODALITY_META[modality].labelKey),
})
: t('{{modality}} not supported', {
modality: t(MODALITY_META[modality].labelKey),
})
}
>
<Icon className='size-4' />
</span>
)
},
})),
]}
/>
)
}
@@ -21,13 +21,6 @@ import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, HeartPulse, Timer } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import {
StaticDataTable,
staticDataTableClassNames as tableStyles,
@@ -256,53 +249,55 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
title={t('Per-group performance')}
description={t('Average latency, TTFT, TPS, and success rate')}
/>
<StaticDataTable className='rounded-lg' tableClassName='text-sm'>
<TableHeader>
<TableRow className={tableStyles.compactHeaderRow}>
<TableHead className={tableStyles.compactHeaderCell}>
{t('Group')}
</TableHead>
<TableHead className={tableStyles.compactHeaderCellRight}>
TPS
</TableHead>
<TableHead className={tableStyles.compactHeaderCellRight}>
{t('Average TTFT')}
</TableHead>
<TableHead className={tableStyles.compactHeaderCellRight}>
{t('Average latency')}
</TableHead>
<TableHead
className={cn(tableStyles.compactHeaderCell, 'min-w-[180px]')}
>
{t('Success rate')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{performances.map((perf) => (
<TableRow key={perf.group}>
<TableCell className={tableStyles.compactCell}>
<GroupBadge group={perf.group} size='sm' />
</TableCell>
<TableCell className={tableStyles.compactNumericCell}>
{formatThroughput(perf.avg_tps)}
</TableCell>
<TableCell className={tableStyles.compactNumericCell}>
{formatLatency(perf.avg_ttft_ms)}
</TableCell>
<TableCell className={tableStyles.compactMutedNumericCell}>
{formatLatency(perf.avg_latency_ms)}
</TableCell>
<TableCell className={tableStyles.compactCell}>
<UptimeSparkline
size='sm'
series={uptimeByGroup[perf.group] ?? []}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</StaticDataTable>
<StaticDataTable
className='rounded-lg'
tableClassName='text-sm'
headerRowClassName={tableStyles.compactHeaderRow}
data={performances}
getRowKey={(perf) => perf.group}
columns={[
{
id: 'group',
header: t('Group'),
className: tableStyles.compactHeaderCell,
cellClassName: tableStyles.compactCell,
cell: (perf) => <GroupBadge group={perf.group} size='sm' />,
},
{
id: 'tps',
header: 'TPS',
className: tableStyles.compactHeaderCellRight,
cellClassName: tableStyles.compactNumericCell,
cell: (perf) => formatThroughput(perf.avg_tps),
},
{
id: 'ttft',
header: t('Average TTFT'),
className: tableStyles.compactHeaderCellRight,
cellClassName: tableStyles.compactNumericCell,
cell: (perf) => formatLatency(perf.avg_ttft_ms),
},
{
id: 'latency',
header: t('Average latency'),
className: tableStyles.compactHeaderCellRight,
cellClassName: tableStyles.compactMutedNumericCell,
cell: (perf) => formatLatency(perf.avg_latency_ms),
},
{
id: 'success',
header: t('Success rate'),
className: cn(tableStyles.compactHeaderCell, 'min-w-[180px]'),
cellClassName: tableStyles.compactCell,
cell: (perf) => (
<UptimeSparkline
size='sm'
series={uptimeByGroup[perf.group] ?? []}
/>
),
},
]}
/>
</section>
<section>
+119 -147
View File
@@ -34,13 +34,6 @@ import {
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { CopyButton } from '@/components/copy-button'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import { sideDrawerContentClassName } from '@/components/drawer-layout'
import { GroupBadge } from '@/components/group-badge'
@@ -708,54 +701,40 @@ function GroupPricingSection(props: {
<StaticDataTable
className='rounded-none border-0'
tableClassName='text-sm'
>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className={thClass}>{t('Tier')}</TableHead>
{priceFields.map((entry) => (
<TableHead
key={entry.field}
className={`${thClass} text-right`}
>
{t(entry.shortLabel)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{dynamicTiers.map((tier, tierIndex) => {
const entries = getDynamicPriceEntries(tier, {
tokenUnit: props.tokenUnit,
showRechargePrice,
priceRate: props.priceRate,
usdExchangeRate: props.usdExchangeRate,
groupRatioMultiplier: ratio,
})
const entryMap = new Map(
entries.map((entry) => [entry.field, entry])
)
return (
<TableRow key={`${group}-${tier.label || tierIndex}`}>
<TableCell className='text-muted-foreground py-2.5'>
{tier.label || t('Default')}
</TableCell>
{priceFields.map((fieldEntry) => {
const entry = entryMap.get(fieldEntry.field)
return (
<TableCell
key={fieldEntry.field}
className='py-2.5 text-right font-mono'
>
{entry?.formatted ?? '-'}
</TableCell>
)
})}
</TableRow>
)
})}
</TableBody>
</StaticDataTable>
headerRowClassName='hover:bg-transparent'
data={dynamicTiers}
getRowKey={(tier, tierIndex) =>
`${group}-${tier.label || tierIndex}`
}
columns={[
{
id: 'tier',
header: t('Tier'),
className: thClass,
cellClassName: 'text-muted-foreground py-2.5',
cell: (tier) => tier.label || t('Default'),
},
...priceFields.map((fieldEntry) => ({
id: fieldEntry.field,
header: t(fieldEntry.shortLabel),
className: `${thClass} text-right`,
cellClassName: 'py-2.5 text-right font-mono',
cell: (tier: (typeof dynamicTiers)[number]) => {
const entries = getDynamicPriceEntries(tier, {
tokenUnit: props.tokenUnit,
showRechargePrice,
priceRate: props.priceRate,
usdExchangeRate: props.usdExchangeRate,
groupRatioMultiplier: ratio,
})
const entryMap = new Map(
entries.map((entry) => [entry.field, entry])
)
return entryMap.get(fieldEntry.field)?.formatted ?? '-'
},
})),
]}
/>
</div>
)
})}
@@ -774,104 +753,97 @@ function GroupPricingSection(props: {
<StaticDataTable
className='-mx-4 rounded-none border-0 sm:mx-0'
tableClassName='text-sm'
>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className={thClass}>{t('Group')}</TableHead>
<TableHead className={thClass}>{t('Ratio')}</TableHead>
{isTokenBased ? (
<>
<TableHead className={`${thClass} text-right`}>
{t('Input')}
</TableHead>
<TableHead className={`${thClass} text-right`}>
{t('Output')}
</TableHead>
{extraPriceTypes.map((ep) => (
<TableHead key={ep.type} className={`${thClass} text-right`}>
{ep.label}
</TableHead>
))}
</>
) : (
<TableHead className={`${thClass} text-right`}>
{t('Price')}
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{availableGroups.map((group) => {
const ratio = props.groupRatio[group] || 1
return (
<TableRow key={group}>
<TableCell className='py-2.5'>
<GroupBadge group={group} size='sm' />
</TableCell>
<TableCell className='text-muted-foreground py-2.5 font-mono'>
{ratio}x
</TableCell>
{isTokenBased ? (
<>
<TableCell className='py-2.5 text-right font-mono'>
{formatGroupPrice(
props.model,
group,
'input',
props.tokenUnit,
showRechargePrice,
props.priceRate,
props.usdExchangeRate,
props.groupRatio
)}
</TableCell>
<TableCell className='py-2.5 text-right font-mono'>
{formatGroupPrice(
props.model,
group,
'output',
props.tokenUnit,
showRechargePrice,
props.priceRate,
props.usdExchangeRate,
props.groupRatio
)}
</TableCell>
{extraPriceTypes.map((ep) => (
<TableCell
key={ep.type}
className='py-2.5 text-right font-mono'
>
{formatGroupPrice(
props.model,
group,
ep.type,
props.tokenUnit,
showRechargePrice,
props.priceRate,
props.usdExchangeRate,
props.groupRatio
)}
</TableCell>
))}
</>
) : (
<TableCell className='py-2.5 text-right font-mono'>
{formatFixedPrice(
headerRowClassName='hover:bg-transparent'
data={availableGroups}
getRowKey={(group) => group}
columns={[
{
id: 'group',
header: t('Group'),
className: thClass,
cellClassName: 'py-2.5',
cell: (group) => <GroupBadge group={group} size='sm' />,
},
{
id: 'ratio',
header: t('Ratio'),
className: thClass,
cellClassName: 'text-muted-foreground py-2.5 font-mono',
cell: (group) => `${props.groupRatio[group] || 1}x`,
},
...(isTokenBased
? [
{
id: 'input',
header: t('Input'),
className: `${thClass} text-right`,
cellClassName: 'py-2.5 text-right font-mono',
cell: (group: string) =>
formatGroupPrice(
props.model,
group,
'input',
props.tokenUnit,
showRechargePrice,
props.priceRate,
props.usdExchangeRate,
props.groupRatio
),
},
{
id: 'output',
header: t('Output'),
className: `${thClass} text-right`,
cellClassName: 'py-2.5 text-right font-mono',
cell: (group: string) =>
formatGroupPrice(
props.model,
group,
'output',
props.tokenUnit,
showRechargePrice,
props.priceRate,
props.usdExchangeRate,
props.groupRatio
),
},
...extraPriceTypes.map((ep) => ({
id: ep.type,
header: ep.label,
className: `${thClass} text-right`,
cellClassName: 'py-2.5 text-right font-mono',
cell: (group: string) =>
formatGroupPrice(
props.model,
group,
ep.type,
props.tokenUnit,
showRechargePrice,
props.priceRate,
props.usdExchangeRate,
props.groupRatio
),
})),
]
: [
{
id: 'price',
header: t('Price'),
className: `${thClass} text-right`,
cellClassName: 'py-2.5 text-right font-mono',
cell: (group: string) =>
formatFixedPrice(
props.model,
group,
showRechargePrice,
props.priceRate,
props.usdExchangeRate,
props.groupRatio
)}
</TableCell>
)}
</TableRow>
)
})}
</TableBody>
</StaticDataTable>
),
},
]),
]}
/>
<div className='-mx-4 sm:mx-0'>
{isTokenBased && (
<p className='text-muted-foreground/40 mt-1.5 px-4 text-[10px] sm:px-0'>
@@ -37,17 +37,7 @@ import {
SheetDescription,
} from '@/components/ui/sheet'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import {
StaticDataTable,
StaticDataTableEmptyRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import {
sideDrawerContentClassName,
sideDrawerFormClassName,
@@ -248,106 +238,120 @@ export function UserSubscriptionsDialog(props: Props) {
</Button>
</div>
<StaticDataTable>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>{t('Plan')}</TableHead>
<TableHead>{t('Status')}</TableHead>
<TableHead>{t('Validity')}</TableHead>
<TableHead>{t('Total Quota')}</TableHead>
<TableHead className='text-right'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<StaticDataTableEmptyRow colSpan={6} className='py-8'>
{t('Loading...')}
</StaticDataTableEmptyRow>
) : subs.length === 0 ? (
<StaticDataTableEmptyRow
colSpan={6}
className='text-muted-foreground py-8'
>
{t('No subscription records')}
</StaticDataTableEmptyRow>
) : (
subs.map((record) => {
<StaticDataTable
data={loading ? [] : subs}
getRowKey={(record) => record.subscription.id}
emptyClassName={loading ? 'py-8' : 'text-muted-foreground py-8'}
emptyContent={
loading ? t('Loading...') : t('No subscription records')
}
columns={[
{
id: 'id',
header: 'ID',
cell: (record) => (
<TableId value={record.subscription.id} />
),
},
{
id: 'plan',
header: t('Plan'),
cell: (record) => {
const sub = record.subscription
return (
<div>
<div className='font-medium'>
{planTitleMap.get(sub.plan_id) || `#${sub.plan_id}`}
</div>
<div className='text-muted-foreground text-sm'>
{t('Source')}: {sub.source || '-'}
</div>
</div>
)
},
},
{
id: 'status',
header: t('Status'),
cell: (record) => (
<SubscriptionStatusBadge
sub={record.subscription}
t={t}
/>
),
},
{
id: 'validity',
header: t('Validity'),
cell: (record) => {
const sub = record.subscription
return (
<div className='text-sm'>
<div>
{t('Start')}: {formatTimestamp(sub.start_time)}
</div>
<div>{t('End')}: {formatTimestamp(sub.end_time)}</div>
</div>
)
},
},
{
id: 'quota',
header: t('Total Quota'),
cell: (record) => {
const sub = record.subscription
const total = Number(sub.amount_total || 0)
const used = Number(sub.amount_used || 0)
return total > 0 ? `${used}/${total}` : t('Unlimited')
},
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (record) => {
const sub = record.subscription
const now = Date.now() / 1000
const isExpired =
(sub.end_time || 0) > 0 && sub.end_time < now
const isActive = sub.status === 'active' && !isExpired
const total = Number(sub.amount_total || 0)
const used = Number(sub.amount_used || 0)
return (
<TableRow key={sub.id}>
<TableCell>
<TableId value={sub.id} />
</TableCell>
<TableCell>
<div>
<div className='font-medium'>
{planTitleMap.get(sub.plan_id) ||
`#${sub.plan_id}`}
</div>
<div className='text-muted-foreground text-sm'>
{t('Source')}: {sub.source || '-'}
</div>
</div>
</TableCell>
<TableCell>
<SubscriptionStatusBadge sub={sub} t={t} />
</TableCell>
<TableCell>
<div className='text-sm'>
<div>
{t('Start')}: {formatTimestamp(sub.start_time)}
</div>
<div>
{t('End')}: {formatTimestamp(sub.end_time)}
</div>
</div>
</TableCell>
<TableCell>
{total > 0 ? `${used}/${total}` : t('Unlimited')}
</TableCell>
<TableCell className='text-right'>
<div className='flex justify-end gap-1'>
<Button
size='sm'
variant='outline'
disabled={!isActive}
onClick={() =>
setConfirmAction({
type: 'invalidate',
subId: sub.id,
})
}
>
{t('Invalidate')}
</Button>
<Button
size='sm'
variant='destructive'
onClick={() =>
setConfirmAction({
type: 'delete',
subId: sub.id,
})
}
>
{t('Delete')}
</Button>
</div>
</TableCell>
</TableRow>
<div className='flex justify-end gap-1'>
<Button
size='sm'
variant='outline'
disabled={!isActive}
onClick={() =>
setConfirmAction({
type: 'invalidate',
subId: sub.id,
})
}
>
{t('Invalidate')}
</Button>
<Button
size='sm'
variant='destructive'
onClick={() =>
setConfirmAction({
type: 'delete',
subId: sub.id,
})
}
>
{t('Delete')}
</Button>
</div>
)
})
)}
</TableBody>
</StaticDataTable>
},
},
]}
/>
</div>
</SheetContent>
</Sheet>
@@ -21,13 +21,6 @@ import { Pencil, Trash2, Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import { useDeleteProvider } from '../hooks/use-custom-oauth-mutations'
@@ -64,73 +57,82 @@ export function ProviderTable(props: ProviderTableProps) {
</Button>
</div>
{props.providers.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
{t('No custom OAuth providers configured yet.')}
</div>
) : (
<StaticDataTable>
<TableHeader>
<TableRow>
<TableHead>{t('Icon')}</TableHead>
<TableHead>{t('Name')}</TableHead>
<TableHead>{t('Slug')}</TableHead>
<TableHead>{t('Status')}</TableHead>
<TableHead>{t('Client ID')}</TableHead>
<TableHead className='text-right'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{props.providers.map((provider) => (
<TableRow key={provider.id}>
<TableCell>
{provider.icon ? (
<span className='text-lg'>{provider.icon}</span>
) : (
<span className='text-muted-foreground text-sm'>--</span>
)}
</TableCell>
<TableCell className='font-medium'>{provider.name}</TableCell>
<TableCell>
<StatusBadge
label={provider.slug}
variant='neutral'
copyable={false}
/>
</TableCell>
<TableCell>
<StatusBadge
label={provider.enabled ? t('Enabled') : t('Disabled')}
variant={provider.enabled ? 'success' : 'neutral'}
copyable={false}
/>
</TableCell>
<TableCell className='text-muted-foreground max-w-[120px] truncate font-mono'>
{provider.client_id}
</TableCell>
<TableCell className='text-right'>
<div className='flex justify-end gap-1'>
<Button
variant='ghost'
size='sm'
onClick={() => props.onEdit(provider)}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => setDeleteTarget(provider)}
>
<Trash2 className='text-destructive h-4 w-4' />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</StaticDataTable>
)}
<StaticDataTable
data={props.providers}
getRowKey={(provider) => provider.id}
emptyClassName='text-sm'
emptyContent={t('No custom OAuth providers configured yet.')}
columns={[
{
id: 'icon',
header: t('Icon'),
cell: (provider) =>
provider.icon ? (
<span className='text-lg'>{provider.icon}</span>
) : (
<span className='text-muted-foreground text-sm'>--</span>
),
},
{
id: 'name',
header: t('Name'),
cellClassName: 'font-medium',
cell: (provider) => provider.name,
},
{
id: 'slug',
header: t('Slug'),
cell: (provider) => (
<StatusBadge
label={provider.slug}
variant='neutral'
copyable={false}
/>
),
},
{
id: 'status',
header: t('Status'),
cell: (provider) => (
<StatusBadge
label={provider.enabled ? t('Enabled') : t('Disabled')}
variant={provider.enabled ? 'success' : 'neutral'}
copyable={false}
/>
),
},
{
id: 'client-id',
header: t('Client ID'),
cellClassName: 'text-muted-foreground max-w-[120px] truncate font-mono',
cell: (provider) => provider.client_id,
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (provider) => (
<div className='flex justify-end gap-1'>
<Button
variant='ghost'
size='sm'
onClick={() => props.onEdit(provider)}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => setDeleteTarget(provider)}
>
<Trash2 className='text-destructive h-4 w-4' />
</Button>
</div>
),
},
]}
/>
<ConfirmDialog
open={!!deleteTarget}
@@ -55,17 +55,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import {
StaticDataTable,
StaticDataTableEmptyRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import { DateTimePicker } from '@/components/datetime-picker'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
@@ -353,10 +343,16 @@ export function AnnouncementsSection({
/>
</div>
<StaticDataTable>
<TableHeader>
<TableRow>
<TableHead className='w-12'>
<StaticDataTable
data={sortedAnnouncements}
getRowKey={(announcement) => announcement.id}
emptyContent={t(
'No announcements yet. Click "Add Announcement" to create one.'
)}
columns={[
{
id: 'select',
header: (
<Checkbox
checked={
selectedIds.length === announcements.length &&
@@ -364,94 +360,87 @@ export function AnnouncementsSection({
}
onCheckedChange={toggleSelectAll}
/>
</TableHead>
<TableHead>{t('Content')}</TableHead>
<TableHead>{t('Publish Date')}</TableHead>
<TableHead>{t('Type')}</TableHead>
<TableHead>{t('Extra')}</TableHead>
<TableHead className='w-32'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedAnnouncements.length === 0 ? (
<StaticDataTableEmptyRow colSpan={6}>
{t(
'No announcements yet. Click "Add Announcement" to create one.'
)}
</StaticDataTableEmptyRow>
) : (
sortedAnnouncements.map((announcement) => (
<TableRow key={announcement.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(announcement.id)}
onCheckedChange={(checked) =>
toggleSelectOne(announcement.id, checked as boolean)
}
/>
</TableCell>
<TableCell
className='max-w-xs truncate'
title={announcement.content}
),
className: 'w-12',
cell: (announcement) => (
<Checkbox
checked={selectedIds.includes(announcement.id)}
onCheckedChange={(checked) =>
toggleSelectOne(announcement.id, checked as boolean)
}
/>
),
},
{
id: 'content',
header: t('Content'),
cellClassName: 'max-w-xs truncate',
cell: (announcement) => announcement.content,
},
{
id: 'publish-date',
header: t('Publish Date'),
cell: (announcement) => (
<div className='flex flex-col gap-1'>
<span className='text-sm font-medium'>
{getRelativeTime(announcement.publishDate)}
</span>
<span className='text-muted-foreground text-xs'>
{dayjs(announcement.publishDate).format(
'YYYY-MM-DD HH:mm:ss'
)}
</span>
</div>
),
},
{
id: 'type',
header: t('Type'),
cell: (announcement) => (
<StatusBadge
label={
typeOptions.find((opt) => opt.value === announcement.type)
?.label
}
variant={
typeOptions.find((opt) => opt.value === announcement.type)
?.badgeVariant ?? 'neutral'
}
copyable={false}
/>
),
},
{
id: 'extra',
header: t('Extra'),
cellClassName: 'text-muted-foreground max-w-xs truncate',
cell: (announcement) => announcement.extra || '-',
},
{
id: 'actions',
header: t('Actions'),
className: 'w-32',
cell: (announcement) => (
<div className='flex gap-2'>
<Button
onClick={() => handleEdit(announcement)}
size='sm'
variant='ghost'
>
{announcement.content}
</TableCell>
<TableCell>
<div className='flex flex-col gap-1'>
<span className='text-sm font-medium'>
{getRelativeTime(announcement.publishDate)}
</span>
<span className='text-muted-foreground text-xs'>
{dayjs(announcement.publishDate).format(
'YYYY-MM-DD HH:mm:ss'
)}
</span>
</div>
</TableCell>
<TableCell>
<StatusBadge
label={
typeOptions.find(
(opt) => opt.value === announcement.type
)?.label
}
variant={
typeOptions.find(
(opt) => opt.value === announcement.type
)?.badgeVariant ?? 'neutral'
}
copyable={false}
/>
</TableCell>
<TableCell
className='text-muted-foreground max-w-xs truncate'
title={announcement.extra}
<Edit className='h-4 w-4' />
</Button>
<Button
onClick={() => handleDelete(announcement)}
size='sm'
variant='ghost'
>
{announcement.extra || '-'}
</TableCell>
<TableCell>
<div className='flex gap-2'>
<Button
onClick={() => handleEdit(announcement)}
size='sm'
variant='ghost'
>
<Edit className='h-4 w-4' />
</Button>
<Button
onClick={() => handleDelete(announcement)}
size='sm'
variant='ghost'
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</StaticDataTable>
<Trash2 className='h-4 w-4' />
</Button>
</div>
),
},
]}
/>
</div>
<Dialog
@@ -54,17 +54,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import {
StaticDataTable,
StaticDataTableEmptyRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { SettingsSwitchField } from '../components/settings-form-layout'
@@ -309,10 +299,14 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
/>
</div>
<StaticDataTable>
<TableHeader>
<TableRow>
<TableHead className='w-12'>
<StaticDataTable
data={apiInfoList}
getRowKey={(apiInfo) => apiInfo.id}
emptyContent={t('No API Domains yet. Click "Add API" to create one.')}
columns={[
{
id: 'select',
header: (
<Checkbox
checked={
selectedIds.length === apiInfoList.length &&
@@ -320,86 +314,83 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
}
onCheckedChange={toggleSelectAll}
/>
</TableHead>
<TableHead>{t('URL')}</TableHead>
<TableHead>{t('Route')}</TableHead>
<TableHead>{t('Description')}</TableHead>
<TableHead>{t('Color')}</TableHead>
<TableHead className='w-32'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiInfoList.length === 0 ? (
<StaticDataTableEmptyRow colSpan={6}>
{t('No API Domains yet. Click "Add API" to create one.')}
</StaticDataTableEmptyRow>
) : (
apiInfoList.map((apiInfo) => (
<TableRow key={apiInfo.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(apiInfo.id)}
onCheckedChange={(checked) =>
toggleSelectOne(apiInfo.id, checked as boolean)
}
/>
</TableCell>
<TableCell
className='max-w-xs truncate font-mono text-sm'
title={apiInfo.url}
),
className: 'w-12',
cell: (apiInfo) => (
<Checkbox
checked={selectedIds.includes(apiInfo.id)}
onCheckedChange={(checked) =>
toggleSelectOne(apiInfo.id, checked as boolean)
}
/>
),
},
{
id: 'url',
header: t('URL'),
cellClassName: 'max-w-xs truncate font-mono text-sm',
cell: (apiInfo) => (
<StatusBadge
label={apiInfo.url}
variant='neutral'
copyable={false}
/>
),
},
{
id: 'route',
header: t('Route'),
cell: (apiInfo) => (
<StatusBadge
label={apiInfo.route}
variant='neutral'
copyable={false}
/>
),
},
{
id: 'description',
header: t('Description'),
cellClassName: 'max-w-xs truncate',
cell: (apiInfo) => apiInfo.description,
},
{
id: 'color',
header: t('Color'),
cell: (apiInfo) => (
<div className='flex items-center gap-2'>
<div
className={`h-4 w-4 rounded-full ${getColorClass(apiInfo.color)}`}
/>
<span className='text-sm capitalize'>{apiInfo.color}</span>
</div>
),
},
{
id: 'actions',
header: t('Actions'),
className: 'w-32',
cell: (apiInfo) => (
<div className='flex gap-2'>
<Button
onClick={() => handleEdit(apiInfo)}
size='sm'
variant='ghost'
>
<StatusBadge
label={apiInfo.url}
variant='neutral'
copyable={false}
/>
</TableCell>
<TableCell>
<StatusBadge
label={apiInfo.route}
variant='neutral'
copyable={false}
/>
</TableCell>
<TableCell
className='max-w-xs truncate'
title={apiInfo.description}
<Edit className='h-4 w-4' />
</Button>
<Button
onClick={() => handleDelete(apiInfo)}
size='sm'
variant='ghost'
>
{apiInfo.description}
</TableCell>
<TableCell>
<div className='flex items-center gap-2'>
<div
className={`h-4 w-4 rounded-full ${getColorClass(apiInfo.color)}`}
/>
<span className='text-sm capitalize'>
{apiInfo.color}
</span>
</div>
</TableCell>
<TableCell>
<div className='flex gap-2'>
<Button
onClick={() => handleEdit(apiInfo)}
size='sm'
variant='ghost'
>
<Edit className='h-4 w-4' />
</Button>
<Button
onClick={() => handleDelete(apiInfo)}
size='sm'
variant='ghost'
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</StaticDataTable>
<Trash2 className='h-4 w-4' />
</Button>
</div>
),
},
]}
/>
</div>
<Dialog
@@ -21,13 +21,6 @@ import { Pencil, Plus, Search, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import { safeJsonParseWithValidation } from '../utils/json-parser'
import { isArray } from '../utils/json-validators'
@@ -149,53 +142,55 @@ export function ChatSettingsVisualEditor({
</Button>
</div>
{filteredChats.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
{searchText
<StaticDataTable
data={filteredChats}
getRowKey={(chat) => chat.name}
emptyContent={
searchText
? t('No chat presets match your search')
: t(
'No chat presets configured. Click "Add chat preset" to get started.'
)}
</div>
) : (
<StaticDataTable>
<TableHeader>
<TableRow>
<TableHead>{t('Chat Client Name')}</TableHead>
<TableHead>{t('URL')}</TableHead>
<TableHead className='text-right'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredChats.map((chat) => (
<TableRow key={chat.name}>
<TableCell className='font-medium'>{chat.name}</TableCell>
<TableCell className='max-w-md truncate font-mono text-sm'>
{chat.url}
</TableCell>
<TableCell className='text-right'>
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => handleEdit(chat)}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleDelete(chat.name)}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</StaticDataTable>
)}
)
}
columns={[
{
id: 'name',
header: t('Chat Client Name'),
cellClassName: 'font-medium',
cell: (chat) => chat.name,
},
{
id: 'url',
header: t('URL'),
cellClassName: 'max-w-md truncate font-mono text-sm',
cell: (chat) => chat.url,
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (chat) => (
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => handleEdit(chat)}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleDelete(chat.name)}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
),
},
]}
/>
<ChatDialog
open={dialogOpen}
@@ -46,17 +46,7 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import {
StaticDataTable,
StaticDataTableEmptyRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
@@ -272,73 +262,68 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
/>
</div>
<StaticDataTable>
<TableHeader>
<TableRow>
<TableHead className='w-12'>
<StaticDataTable
data={faqList}
getRowKey={(faq) => faq.id}
emptyContent={t('No FAQ entries yet. Click "Add FAQ" to create one.')}
columns={[
{
id: 'select',
header: (
<Checkbox
checked={
selectedIds.length === faqList.length && faqList.length > 0
}
onCheckedChange={toggleSelectAll}
/>
</TableHead>
<TableHead>{t('Question')}</TableHead>
<TableHead>{t('Answer')}</TableHead>
<TableHead className='w-32'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{faqList.length === 0 ? (
<StaticDataTableEmptyRow colSpan={4}>
{t('No FAQ entries yet. Click "Add FAQ" to create one.')}
</StaticDataTableEmptyRow>
) : (
faqList.map((faq) => (
<TableRow key={faq.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(faq.id)}
onCheckedChange={(checked) =>
toggleSelectOne(faq.id, checked as boolean)
}
/>
</TableCell>
<TableCell
className='max-w-xs truncate font-medium'
title={faq.question}
),
className: 'w-12',
cell: (faq) => (
<Checkbox
checked={selectedIds.includes(faq.id)}
onCheckedChange={(checked) =>
toggleSelectOne(faq.id, checked as boolean)
}
/>
),
},
{
id: 'question',
header: t('Question'),
cellClassName: 'max-w-xs truncate font-medium',
cell: (faq) => faq.question,
},
{
id: 'answer',
header: t('Answer'),
cellClassName: 'text-muted-foreground max-w-md truncate',
cell: (faq) => faq.answer,
},
{
id: 'actions',
header: t('Actions'),
className: 'w-32',
cell: (faq) => (
<div className='flex gap-2'>
<Button
onClick={() => handleEdit(faq)}
size='sm'
variant='ghost'
>
{faq.question}
</TableCell>
<TableCell
className='text-muted-foreground max-w-md truncate'
title={faq.answer}
<Edit className='h-4 w-4' />
</Button>
<Button
onClick={() => handleDelete(faq)}
size='sm'
variant='ghost'
>
{faq.answer}
</TableCell>
<TableCell>
<div className='flex gap-2'>
<Button
onClick={() => handleEdit(faq)}
size='sm'
variant='ghost'
>
<Edit className='h-4 w-4' />
</Button>
<Button
onClick={() => handleDelete(faq)}
size='sm'
variant='ghost'
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</StaticDataTable>
<Trash2 className='h-4 w-4' />
</Button>
</div>
),
},
]}
/>
</div>
<Dialog
@@ -45,17 +45,7 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import {
StaticDataTable,
StaticDataTableEmptyRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
@@ -281,76 +271,77 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
/>
</div>
<StaticDataTable>
<TableHeader>
<TableRow>
<TableHead className='w-12'>
<StaticDataTable
data={groups}
getRowKey={(group) => group.id}
emptyContent={t(
'No Uptime Kuma groups yet. Click "Add Group" to create one.'
)}
columns={[
{
id: 'select',
header: (
<Checkbox
checked={
selectedIds.length === groups.length && groups.length > 0
}
onCheckedChange={toggleSelectAll}
/>
</TableHead>
<TableHead>{t('Category Name')}</TableHead>
<TableHead>{t('Uptime Kuma URL')}</TableHead>
<TableHead>{t('Status Page Slug')}</TableHead>
<TableHead className='w-32'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.length === 0 ? (
<StaticDataTableEmptyRow colSpan={5}>
{t(
'No Uptime Kuma groups yet. Click "Add Group" to create one.'
)}
</StaticDataTableEmptyRow>
) : (
groups.map((group) => (
<TableRow key={group.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(group.id)}
onCheckedChange={(checked) =>
toggleSelectOne(group.id, checked as boolean)
}
/>
</TableCell>
<TableCell className='font-medium'>
{group.categoryName}
</TableCell>
<TableCell
className='text-primary max-w-xs truncate font-mono text-sm'
title={group.url}
),
className: 'w-12',
cell: (group) => (
<Checkbox
checked={selectedIds.includes(group.id)}
onCheckedChange={(checked) =>
toggleSelectOne(group.id, checked as boolean)
}
/>
),
},
{
id: 'category',
header: t('Category Name'),
cellClassName: 'font-medium',
cell: (group) => group.categoryName,
},
{
id: 'url',
header: t('Uptime Kuma URL'),
cellClassName:
'text-primary max-w-xs truncate font-mono text-sm',
cell: (group) => group.url,
},
{
id: 'slug',
header: t('Status Page Slug'),
cellClassName: 'text-muted-foreground font-mono text-sm',
cell: (group) => group.slug,
},
{
id: 'actions',
header: t('Actions'),
className: 'w-32',
cell: (group) => (
<div className='flex gap-2'>
<Button
onClick={() => handleEdit(group)}
size='sm'
variant='ghost'
>
{group.url}
</TableCell>
<TableCell className='text-muted-foreground font-mono text-sm'>
{group.slug}
</TableCell>
<TableCell>
<div className='flex gap-2'>
<Button
onClick={() => handleEdit(group)}
size='sm'
variant='ghost'
>
<Edit className='h-4 w-4' />
</Button>
<Button
onClick={() => handleDelete(group)}
size='sm'
variant='ghost'
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</StaticDataTable>
<Edit className='h-4 w-4' />
</Button>
<Button
onClick={() => handleDelete(group)}
size='sm'
variant='ghost'
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
),
},
]}
/>
</div>
<Dialog
@@ -32,17 +32,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import {
StaticDataTable,
StaticDataTableEmptyRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
import { SettingsSwitchField } from '../../components/settings-form-layout'
@@ -549,114 +539,117 @@ export function ChannelAffinitySection(props: Props) {
{/* Rules Table or JSON Editor */}
{editMode === 'visual' ? (
<StaticDataTable tableClassName='min-w-max'>
<TableHeader>
<TableRow>
<TableHead>{t('Name')}</TableHead>
<TableHead>{t('Model Regex')}</TableHead>
<TableHead>{t('Key Sources')}</TableHead>
<TableHead>{t('TTL')}</TableHead>
<TableHead>{t('Retry')}</TableHead>
<TableHead>{t('Scope')}</TableHead>
<TableHead>{t('Cache')}</TableHead>
<TableHead className='text-right'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rules.length === 0 ? (
<StaticDataTableEmptyRow
colSpan={8}
className='text-muted-foreground py-8'
>
{t('No rules yet')}
</StaticDataTableEmptyRow>
) : (
rules.map((rule, idx) => (
<TableRow key={idx}>
<TableCell className='font-medium'>
{rule.name || '-'}
</TableCell>
<TableCell>
<RuleBadgeList items={rule.model_regex || []} />
</TableCell>
<TableCell>
<RuleBadgeList
items={(rule.key_sources || []).map(
(src) =>
`${src.type}:${src.type === 'gjson' ? src.path : src.key}`
)}
/>
</TableCell>
<TableCell>{rule.ttl_seconds || '-'}</TableCell>
<TableCell>
<StatusBadge
label={
rule.skip_retry_on_failure
? t('No Retry')
: t('Retry')
}
variant={
rule.skip_retry_on_failure ? 'danger' : 'neutral'
}
copyable={false}
/>
</TableCell>
<TableCell>
{(() => {
const scopeItems = [
rule.include_using_group && t('Group'),
rule.include_model_name && t('Model'),
rule.include_rule_name && t('Rule'),
].filter(Boolean) as string[]
if (scopeItems.length === 0) return '-'
return <RuleBadgeList items={scopeItems} />
})()}
</TableCell>
<TableCell>
{rule.include_rule_name && cacheStats?.by_rule_name
? cacheStats.by_rule_name[rule.name] || 0
: 'N/A'}
</TableCell>
<TableCell className='text-right'>
<div className='flex justify-end gap-1'>
{rule.include_rule_name && (
<Button
variant='ghost'
size='icon'
className='h-7 w-7'
onClick={() => setClearRuleName(rule.name)}
title={t('Clear cache for this rule')}
>
<X className='h-3 w-3' />
</Button>
)}
<Button
variant='ghost'
size='icon'
className='h-7 w-7'
onClick={() => {
setEditingRule(rule)
setRuleTemplateKey(null)
setRuleEditorOpen(true)
}}
>
<Edit className='h-3 w-3' />
</Button>
<Button
variant='ghost'
size='icon'
className='h-7 w-7'
onClick={() => handleDeleteRule(idx)}
>
<Trash2 className='h-3 w-3' />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</StaticDataTable>
<StaticDataTable
tableClassName='min-w-max'
data={rules}
emptyClassName='text-muted-foreground py-8'
emptyContent={t('No rules yet')}
columns={[
{
id: 'name',
header: t('Name'),
cellClassName: 'font-medium',
cell: (rule) => rule.name || '-',
},
{
id: 'model-regex',
header: t('Model Regex'),
cell: (rule) => <RuleBadgeList items={rule.model_regex || []} />,
},
{
id: 'key-sources',
header: t('Key Sources'),
cell: (rule) => (
<RuleBadgeList
items={(rule.key_sources || []).map(
(src) =>
`${src.type}:${src.type === 'gjson' ? src.path : src.key}`
)}
/>
),
},
{
id: 'ttl',
header: t('TTL'),
cell: (rule) => rule.ttl_seconds || '-',
},
{
id: 'retry',
header: t('Retry'),
cell: (rule) => (
<StatusBadge
label={
rule.skip_retry_on_failure ? t('No Retry') : t('Retry')
}
variant={rule.skip_retry_on_failure ? 'danger' : 'neutral'}
copyable={false}
/>
),
},
{
id: 'scope',
header: t('Scope'),
cell: (rule) => {
const scopeItems = [
rule.include_using_group && t('Group'),
rule.include_model_name && t('Model'),
rule.include_rule_name && t('Rule'),
].filter(Boolean) as string[]
if (scopeItems.length === 0) return '-'
return <RuleBadgeList items={scopeItems} />
},
},
{
id: 'cache',
header: t('Cache'),
cell: (rule) =>
rule.include_rule_name && cacheStats?.by_rule_name
? cacheStats.by_rule_name[rule.name] || 0
: 'N/A',
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (rule, idx) => (
<div className='flex justify-end gap-1'>
{rule.include_rule_name && (
<Button
variant='ghost'
size='icon'
className='h-7 w-7'
onClick={() => setClearRuleName(rule.name)}
title={t('Clear cache for this rule')}
>
<X className='h-3 w-3' />
</Button>
)}
<Button
variant='ghost'
size='icon'
className='h-7 w-7'
onClick={() => {
setEditingRule(rule)
setRuleTemplateKey(null)
setRuleEditorOpen(true)
}}
>
<Edit className='h-3 w-3' />
</Button>
<Button
variant='ghost'
size='icon'
className='h-7 w-7'
onClick={() => handleDeleteRule(idx)}
>
<Trash2 className='h-3 w-3' />
</Button>
</div>
),
},
]}
/>
) : (
<div className='grid gap-1.5'>
<Label>{t('Rules JSON')}</Label>
@@ -20,13 +20,6 @@ import { useState, useMemo } from 'react'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import { safeJsonParseWithValidation } from '../utils/json-parser'
@@ -147,69 +140,78 @@ export function AmountDiscountVisualEditor({
) : (
<div className='rounded-md border'>
{/* Desktop table view */}
<StaticDataTable className='hidden rounded-none border-0 sm:block'>
<TableHeader>
<TableRow>
<TableHead>{t('Recharge Amount')}</TableHead>
<TableHead>{t('Discount Rate')}</TableHead>
<TableHead>{t('Discount')}</TableHead>
<TableHead className='text-right'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{discounts.map((discount) => (
<TableRow key={discount.amount}>
<TableCell>
<span className='font-mono text-sm'>
${discount.amount}
</span>
</TableCell>
<TableCell>
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
{discount.discountRate.toFixed(2)}
</code>
</TableCell>
<TableCell>
<StatusBadge
variant={discount.discountRate < 1 ? 'info' : 'neutral'}
className='font-mono'
copyable={false}
<StaticDataTable
className='hidden rounded-none border-0 sm:block'
data={discounts}
getRowKey={(discount) => discount.amount}
columns={[
{
id: 'amount',
header: t('Recharge Amount'),
cell: (discount) => (
<span className='font-mono text-sm'>
${discount.amount}
</span>
),
},
{
id: 'discount-rate',
header: t('Discount Rate'),
cell: (discount) => (
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
{discount.discountRate.toFixed(2)}
</code>
),
},
{
id: 'discount',
header: t('Discount'),
cell: (discount) => (
<StatusBadge
variant={discount.discountRate < 1 ? 'info' : 'neutral'}
className='font-mono'
copyable={false}
>
{formatPercentage(discount.discountRate)} {t('off')}
</StatusBadge>
),
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (discount) => (
<div className='flex justify-end gap-2'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleEdit(discount)
}}
>
{formatPercentage(discount.discountRate)} {t('off')}
</StatusBadge>
</TableCell>
<TableCell className='text-right'>
<div className='flex justify-end gap-2'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleEdit(discount)
}}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleDelete(discount.amount)
}}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</StaticDataTable>
<Pencil className='h-4 w-4' />
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleDelete(discount.amount)
}}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
),
},
]}
/>
{/* Mobile card view */}
<div className='divide-y sm:hidden'>
@@ -21,13 +21,6 @@ import { Pencil, Plus, Search, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import {
formatCreemPrice,
@@ -183,67 +176,80 @@ export function CreemProductsVisualEditor({
) : (
<div className='rounded-md border'>
{/* Desktop table view */}
<StaticDataTable className='hidden rounded-none border-0 md:block'>
<TableHeader>
<TableRow>
<TableHead>{t('Name')}</TableHead>
<TableHead>{t('Product ID')}</TableHead>
<TableHead>{t('Price')}</TableHead>
<TableHead>{t('Quota')}</TableHead>
<TableHead className='text-right'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredProducts.map((product) => (
<TableRow key={product.productId}>
<TableCell className='font-medium'>{product.name}</TableCell>
<TableCell>
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
{product.productId}
</code>
</TableCell>
<TableCell>
<span className='font-mono text-sm'>
{formatCreemPrice(product.price, product.currency)}
</span>
</TableCell>
<TableCell>
<span className='font-mono text-sm'>
{formatQuotaShort(product.quota)}
</span>
</TableCell>
<TableCell className='text-right'>
<div className='flex justify-end gap-2'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleEdit(product)
}}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleDelete(product)
}}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</StaticDataTable>
<StaticDataTable
className='hidden rounded-none border-0 md:block'
data={filteredProducts}
getRowKey={(product) => product.productId}
columns={[
{
id: 'name',
header: t('Name'),
cellClassName: 'font-medium',
cell: (product) => product.name,
},
{
id: 'product-id',
header: t('Product ID'),
cell: (product) => (
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
{product.productId}
</code>
),
},
{
id: 'price',
header: t('Price'),
cell: (product) => (
<span className='font-mono text-sm'>
{formatCreemPrice(product.price, product.currency)}
</span>
),
},
{
id: 'quota',
header: t('Quota'),
cell: (product) => (
<span className='font-mono text-sm'>
{formatQuotaShort(product.quota)}
</span>
),
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (product) => (
<div className='flex justify-end gap-2'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleEdit(product)
}}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleDelete(product)
}}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
),
},
]}
/>
{/* Mobile card view */}
<div className='divide-y md:hidden'>
@@ -27,13 +27,8 @@ import {
PopoverTrigger,
} from '@/components/ui/popover'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
StaticDataTable,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import { safeJsonParseWithValidation } from '../utils/json-parser'
import { isArray } from '../utils/json-validators'
import {
@@ -291,82 +286,95 @@ export function PaymentMethodsVisualEditor({
) : (
<div className='rounded-md border'>
{/* Desktop table view */}
<StaticDataTable className='hidden rounded-none border-0 md:block'>
<TableHeader>
<TableRow>
<TableHead>{t('Name')}</TableHead>
<TableHead>{t('Type')}</TableHead>
<TableHead>{t('Color')}</TableHead>
<TableHead>{t('Min Top-up')}</TableHead>
<TableHead className='text-right'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredMethods.map((method, index) => {
const colorPreview = getColorPreview(method.color)
return (
<TableRow key={`${method.type}-${index}`}>
<TableCell className='font-medium'>{method.name}</TableCell>
<TableCell>
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
{method.type}
</code>
</TableCell>
<TableCell>
<div className='flex items-center gap-2'>
{colorPreview && (
<div
className='size-5 shrink-0 rounded border'
style={{ backgroundColor: colorPreview }}
/>
)}
<span className='text-muted-foreground truncate font-mono text-sm'>
{method.color}
</span>
</div>
</TableCell>
<TableCell>
{method.min_topup ? (
<span className='font-mono text-sm'>
{method.min_topup}
</span>
) : (
<span className='text-muted-foreground text-sm'></span>
<StaticDataTable
className='hidden rounded-none border-0 md:block'
data={filteredMethods}
getRowKey={(method, index) => `${method.type}-${index}`}
columns={[
{
id: 'name',
header: t('Name'),
cellClassName: 'font-medium',
cell: (method) => method.name,
},
{
id: 'type',
header: t('Type'),
cell: (method) => (
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
{method.type}
</code>
),
},
{
id: 'color',
header: t('Color'),
cell: (method) => {
const colorPreview = getColorPreview(method.color)
return (
<div className='flex items-center gap-2'>
{colorPreview && (
<div
className='size-5 shrink-0 rounded border'
style={{ backgroundColor: colorPreview }}
/>
)}
</TableCell>
<TableCell className='text-right'>
<div className='flex justify-end gap-2'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleEdit(method)
}}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleDelete(method)
}}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</StaticDataTable>
<span className='text-muted-foreground truncate font-mono text-sm'>
{method.color}
</span>
</div>
)
},
},
{
id: 'min-top-up',
header: t('Min Top-up'),
cell: (method) =>
method.min_topup ? (
<span className='font-mono text-sm'>
{method.min_topup}
</span>
) : (
<span className='text-muted-foreground text-sm'></span>
),
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (method) => (
<div className='flex justify-end gap-2'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleEdit(method)
}}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleDelete(method)
}}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
),
},
]}
/>
{/* Mobile card view */}
<div className='divide-y md:hidden'>
@@ -26,17 +26,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import {
StaticDataTable,
StaticDataTableEmptyRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../components/settings-form-layout'
@@ -336,72 +326,74 @@ export function WaffoSettingsSection({
</Button>
</div>
<StaticDataTable>
<TableHeader>
<TableRow>
<TableHead>{t('Display name')}</TableHead>
<TableHead>{t('Icon')}</TableHead>
<TableHead>{t('Payment method type')}</TableHead>
<TableHead>{t('Payment method name')}</TableHead>
<TableHead className='text-right'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{payMethods.length === 0 ? (
<StaticDataTableEmptyRow
colSpan={5}
className='text-muted-foreground py-8'
>
{t('No payment methods configured')}
</StaticDataTableEmptyRow>
) : (
payMethods.map((m, idx) => (
<TableRow key={idx}>
<TableCell>{m.name}</TableCell>
<TableCell>
{m.icon ? (
<img
src={m.icon}
alt={m.name}
className='h-6 w-6 rounded object-contain'
/>
) : (
<span className='text-muted-foreground'>-</span>
)}
</TableCell>
<TableCell>{m.payMethodType || '-'}</TableCell>
<TableCell>{m.payMethodName || '-'}</TableCell>
<TableCell className='text-right'>
<div className='flex justify-end gap-1'>
<Button
type='button'
variant='ghost'
size='icon'
className='h-7 w-7'
onClick={() => openEdit(idx)}
>
<Pencil className='h-3 w-3' />
</Button>
<Button
type='button'
variant='ghost'
size='icon'
className='h-7 w-7'
onClick={() =>
onPayMethodsChange((prev) =>
prev.filter((_, i) => i !== idx)
)
}
>
<Trash2 className='h-3 w-3' />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</StaticDataTable>
<StaticDataTable
data={payMethods}
emptyClassName='text-muted-foreground py-8'
emptyContent={t('No payment methods configured')}
columns={[
{
id: 'name',
header: t('Display name'),
cell: (m) => m.name,
},
{
id: 'icon',
header: t('Icon'),
cell: (m) =>
m.icon ? (
<img
src={m.icon}
alt={m.name}
className='h-6 w-6 rounded object-contain'
/>
) : (
<span className='text-muted-foreground'>-</span>
),
},
{
id: 'type',
header: t('Payment method type'),
cell: (m) => m.payMethodType || '-',
},
{
id: 'method',
header: t('Payment method name'),
cell: (m) => m.payMethodName || '-',
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (_m, idx) => (
<div className='flex justify-end gap-1'>
<Button
type='button'
variant='ghost'
size='icon'
className='h-7 w-7'
onClick={() => openEdit(idx)}
>
<Pencil className='h-3 w-3' />
</Button>
<Button
type='button'
variant='ghost'
size='icon'
className='h-7 w-7'
onClick={() =>
onPayMethodsChange((prev) =>
prev.filter((_, i) => i !== idx)
)
}
>
<Trash2 className='h-3 w-3' />
</Button>
</div>
),
},
]}
/>
</div>
<Dialog
@@ -28,13 +28,8 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
StaticDataTable,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
export type ConflictItem = {
channel: string
@@ -71,38 +66,42 @@ export function ConflictConfirmDialog({
</AlertDialogDescription>
</AlertDialogHeader>
<StaticDataTable className='max-h-96 overflow-y-auto'>
<TableHeader>
<TableRow>
<TableHead>{t('Channel')}</TableHead>
<TableHead>{t('Model')}</TableHead>
<TableHead>{t('Current Billing')}</TableHead>
<TableHead>{t('Change To')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{conflicts.map((conflict, index) => (
<TableRow key={index}>
<TableCell className='font-medium'>
{conflict.channel}
</TableCell>
<TableCell className='font-mono text-sm'>
{conflict.model}
</TableCell>
<TableCell>
<StaticDataTable
className='max-h-96 overflow-y-auto'
data={conflicts}
columns={[
{
id: 'channel',
header: t('Channel'),
cellClassName: 'font-medium',
cell: (conflict) => conflict.channel,
},
{
id: 'model',
header: t('Model'),
cellClassName: 'font-mono text-sm',
cell: (conflict) => conflict.model,
},
{
id: 'current',
header: t('Current Billing'),
cell: (conflict) => (
<pre className='text-sm whitespace-pre-wrap'>
{conflict.current}
</pre>
</TableCell>
<TableCell>
),
},
{
id: 'new',
header: t('Change To'),
cell: (conflict) => (
<pre className='text-sm whitespace-pre-wrap'>
{conflict.newVal}
</pre>
</TableCell>
</TableRow>
))}
</TableBody>
</StaticDataTable>
),
},
]}
/>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>
@@ -35,17 +35,7 @@ import {
} from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import {
StaticDataTable,
StaticDataTableEmptyRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { safeJsonParse } from '../utils/json-parser'
@@ -430,47 +420,51 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
{t('Add group')}
</Button>
{topupRatioList.length > 0 && (
<StaticDataTable>
<TableHeader>
<TableRow>
<TableHead>{t('Group name')}</TableHead>
<TableHead>{t('Multiplier')}</TableHead>
<TableHead className='text-right'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topupRatioList.map((group) => (
<TableRow key={group.name}>
<TableCell className='font-medium'>
{group.name}
</TableCell>
<TableCell>{group.value}</TableCell>
<TableCell className='text-right'>
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() =>
handleSimpleEdit('topupGroupRatio', group)
}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() =>
handleSimpleDelete('topupGroupRatio', group.name)
}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</StaticDataTable>
<StaticDataTable
data={topupRatioList}
getRowKey={(group) => group.name}
columns={[
{
id: 'group',
header: t('Group name'),
cellClassName: 'font-medium',
cell: (group) => group.name,
},
{
id: 'multiplier',
header: t('Multiplier'),
cell: (group) => group.value,
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (group) => (
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() =>
handleSimpleEdit('topupGroupRatio', group)
}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() =>
handleSimpleDelete('topupGroupRatio', group.name)
}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
),
},
]}
/>
)}
</div>
</CardContent>
@@ -537,55 +531,58 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
<CollapsibleContent>
{userGroupData.overrides.length > 0 && (
<div className='border-t'>
<StaticDataTable className='rounded-none border-0'>
<TableHeader>
<TableRow>
<TableHead>{t('Target group')}</TableHead>
<TableHead>{t('Ratio')}</TableHead>
<TableHead className='text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{userGroupData.overrides.map((override) => (
<TableRow key={override.targetGroup}>
<TableCell className='font-medium'>
{override.targetGroup}
</TableCell>
<TableCell>{override.ratio}</TableCell>
<TableCell className='text-right'>
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() =>
handleOverrideEdit(
userGroupData.userGroup,
override
)
}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() =>
handleOverrideDelete(
userGroupData.userGroup,
override.targetGroup
)
}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</StaticDataTable>
<StaticDataTable
className='rounded-none border-0'
data={userGroupData.overrides}
getRowKey={(override) => override.targetGroup}
columns={[
{
id: 'target-group',
header: t('Target group'),
cellClassName: 'font-medium',
cell: (override) => override.targetGroup,
},
{
id: 'ratio',
header: t('Ratio'),
cell: (override) => override.ratio,
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (override) => (
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() =>
handleOverrideEdit(
userGroupData.userGroup,
override
)
}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() =>
handleOverrideDelete(
userGroupData.userGroup,
override.targetGroup
)
}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
),
},
]}
/>
</div>
)}
</CollapsibleContent>
@@ -854,100 +851,99 @@ function GroupPricingTable({
</CardHeader>
<CardContent>
<div className='space-y-3'>
<StaticDataTable>
<TableHeader>
<TableRow>
<TableHead className='min-w-40'>{t('Group name')}</TableHead>
<TableHead className='w-28'>{t('Ratio')}</TableHead>
<TableHead className='w-28 text-center'>
{t('User selectable')}
</TableHead>
<TableHead className='min-w-56'>{t('Description')}</TableHead>
<TableHead className='w-16 text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<StaticDataTableEmptyRow
colSpan={5}
className='text-muted-foreground h-20 text-sm'
>
{t('No groups yet. Add a group to get started.')}
</StaticDataTableEmptyRow>
) : (
rows.map((row) => (
<TableRow key={row._id}>
<TableCell>
<Input
value={row.name}
onChange={(event) =>
updateRow(row._id, 'name', event.target.value)
}
aria-invalid={duplicateNames.includes(row.name.trim())}
/>
</TableCell>
<TableCell>
<Input
type='number'
min={0}
step={0.1}
value={String(row.ratio)}
onChange={(event) =>
updateRow(
row._id,
'ratio',
normalizeRatio(event.target.value)
)
}
/>
</TableCell>
<TableCell>
<div className='flex justify-center'>
<Checkbox
checked={row.selectable}
onCheckedChange={(checked) =>
updateRow(row._id, 'selectable', checked === true)
}
aria-label={t('User selectable')}
/>
</div>
</TableCell>
<TableCell>
{row.selectable ? (
<Input
value={row.description}
placeholder={t('Group description')}
onChange={(event) =>
updateRow(
row._id,
'description',
event.target.value
)
}
/>
) : (
<span className='text-muted-foreground px-3 text-sm'>
-
</span>
)}
</TableCell>
<TableCell className='text-right'>
<Button
variant='ghost'
size='sm'
onClick={() => removeRow(row._id)}
aria-label={t('Delete')}
>
<Trash2 className='h-4 w-4' />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</StaticDataTable>
<StaticDataTable
data={rows}
getRowKey={(row) => row._id}
emptyClassName='text-muted-foreground h-20 text-sm'
emptyContent={t('No groups yet. Add a group to get started.')}
columns={[
{
id: 'group',
header: t('Group name'),
className: 'min-w-40',
cell: (row) => (
<Input
value={row.name}
onChange={(event) =>
updateRow(row._id, 'name', event.target.value)
}
aria-invalid={duplicateNames.includes(row.name.trim())}
/>
),
},
{
id: 'ratio',
header: t('Ratio'),
className: 'w-28',
cell: (row) => (
<Input
type='number'
min={0}
step={0.1}
value={String(row.ratio)}
onChange={(event) =>
updateRow(
row._id,
'ratio',
normalizeRatio(event.target.value)
)
}
/>
),
},
{
id: 'selectable',
header: t('User selectable'),
className: 'w-28 text-center',
cell: (row) => (
<div className='flex justify-center'>
<Checkbox
checked={row.selectable}
onCheckedChange={(checked) =>
updateRow(row._id, 'selectable', checked === true)
}
aria-label={t('User selectable')}
/>
</div>
),
},
{
id: 'description',
header: t('Description'),
className: 'min-w-56',
cell: (row) =>
row.selectable ? (
<Input
value={row.description}
placeholder={t('Group description')}
onChange={(event) =>
updateRow(row._id, 'description', event.target.value)
}
/>
) : (
<span className='text-muted-foreground px-3 text-sm'>
-
</span>
),
},
{
id: 'actions',
header: t('Actions'),
className: 'w-16 text-right',
cellClassName: 'text-right',
cell: (row) => (
<Button
variant='ghost'
size='sm'
onClick={() => removeRow(row._id)}
aria-label={t('Delete')}
>
<Trash2 className='h-4 w-4' />
</Button>
),
},
]}
/>
{duplicateNames.length > 0 && (
<p className='text-destructive text-sm'>
@@ -24,17 +24,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import {
StaticDataTable,
StaticDataTableEmptyRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import { useUpdateOption } from '../hooks/use-update-option'
const OPTION_KEY = 'tool_price_setting.prices'
@@ -263,37 +253,28 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
</div>
{editMode === 'visual' ? (
<StaticDataTable>
<TableHeader>
<TableRow>
<TableHead>{t('Tool identifier')}</TableHead>
<TableHead className='w-[200px]'>
{t('Price ($/1K calls)')}
</TableHead>
<TableHead className='w-[80px] text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<StaticDataTableEmptyRow
colSpan={3}
className='text-muted-foreground py-8'
>
{t('No tools configured')}
</StaticDataTableEmptyRow>
) : (
rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<StaticDataTable
data={rows}
getRowKey={(row) => row.id}
emptyClassName='text-muted-foreground py-8'
emptyContent={t('No tools configured')}
columns={[
{
id: 'tool',
header: t('Tool identifier'),
cell: (row) => (
<Input
value={row.key}
placeholder='web_search_preview:gpt-4o*'
onChange={(e) => updateRow(row.id, 'key', e.target.value)}
/>
</TableCell>
<TableCell>
),
},
{
id: 'price',
header: t('Price ($/1K calls)'),
className: 'w-[200px]',
cell: (row) => (
<Input
type='number'
min={0}
@@ -303,8 +284,14 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
updateRow(row.id, 'price', Number(e.target.value) || 0)
}
/>
</TableCell>
<TableCell className='text-right'>
),
},
{
id: 'actions',
header: t('Actions'),
className: 'w-[80px] text-right',
cellClassName: 'text-right',
cell: (row) => (
<Button
variant='ghost'
size='icon'
@@ -313,12 +300,10 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
>
<Trash2 className='text-destructive h-4 w-4' />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</StaticDataTable>
),
},
]}
/>
) : (
<div className='space-y-2'>
<Textarea
@@ -21,13 +21,6 @@ import { Pencil, Plus, Search, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import { safeJsonParseWithValidation } from '../utils/json-parser'
import { isObjectRecord } from '../utils/json-validators'
@@ -142,65 +135,73 @@ export function RateLimitVisualEditor({
</Button>
</div>
{filteredRateLimits.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
{searchText
<StaticDataTable
data={filteredRateLimits}
getRowKey={(limit) => limit.groupName}
emptyContent={
searchText
? t('No groups match your search')
: t(
'No group-based rate limits configured. Click "Add group" to get started.'
)}
</div>
) : (
<StaticDataTable>
<TableHeader>
<TableRow>
<TableHead>{t('Group Name')}</TableHead>
<TableHead className='text-right'>
{t('Max Requests (incl. failures)')}
</TableHead>
<TableHead className='text-right'>{t('Max Success')}</TableHead>
<TableHead className='text-right'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRateLimits.map((limit) => (
<TableRow key={limit.groupName}>
<TableCell className='font-medium'>{limit.groupName}</TableCell>
<TableCell className='text-right'>
<span className='font-mono'>
{limit.maxRequests === 0
? t('Unlimited')
: limit.maxRequests.toLocaleString()}
</span>
</TableCell>
<TableCell className='text-right'>
<span className='font-mono'>
{limit.maxSuccess.toLocaleString()}
</span>
</TableCell>
<TableCell className='text-right'>
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => handleEdit(limit)}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleDelete(limit.groupName)}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</StaticDataTable>
)}
)
}
columns={[
{
id: 'group',
header: t('Group Name'),
cellClassName: 'font-medium',
cell: (limit) => limit.groupName,
},
{
id: 'max-requests',
header: t('Max Requests (incl. failures)'),
className: 'text-right',
cellClassName: 'text-right',
cell: (limit) => (
<span className='font-mono'>
{limit.maxRequests === 0
? t('Unlimited')
: limit.maxRequests.toLocaleString()}
</span>
),
},
{
id: 'max-success',
header: t('Max Success'),
className: 'text-right',
cellClassName: 'text-right',
cell: (limit) => (
<span className='font-mono'>
{limit.maxSuccess.toLocaleString()}
</span>
),
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (limit) => (
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => handleEdit(limit)}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleDelete(limit.groupName)}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
),
},
]}
/>
<RateLimitDialog
open={dialogOpen}