perf(web): improve frontend table rendering and pinned columns/UI table (#5405)

* refactor(web): centralize data table implementation

- route all TanStack table setup through a shared data-table hook to remove repeated state and row model wiring.
- move table rendering, static table wrappers, empty states, and primitive exports behind the data-table module.
- update feature tables and configuration editors to share the same table UX while preserving their existing workflows.

* refactor(web): trim data table public API

- remove unused data-table exports and dead static table helper types.
- keep internal table header, skeleton, empty state, and faceted filter helpers private to the data-table module.
- route feature imports through the data-table barrel to avoid subpath coupling.

* 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.

* perf(web): keep list tables fixed within page content

- make shared data table pages fill available height and scroll row data inside the table body.
- add a fixed content layout mode so selected list pages avoid page-level scrolling.
- apply the fixed table behavior to keys, logs, channels, models, users, redemptions, and subscriptions.

* perf(web): refine table pagination controls

- show total row counts instead of redundant page range text.
- tighten visible page buttons so pagination fits constrained table widths.
- align pagination controls and tune text hierarchy for clearer scanning.

* perf(web): stabilize model pricing table columns

- keep model pricing columns at fixed widths so headers do not collapse in narrow layouts.
- truncate long model names and pricing summaries within their cells instead of squeezing adjacent columns.

* refactor(web): simplify data table rendering internals

- split table body rendering into focused helpers for loading, empty, and row states.
- extract static table row and cell class resolution to reduce branching in the main component.
- reuse a single pagination page-size option list to avoid duplicated constants.

* perf(pricing): reduce dynamic pricing table render work

- reuse dynamic pricing field metadata instead of rebuilding it inside table columns.
- precompute formatted dynamic prices per tier and group to avoid repeated entry mapping for each cell.
- simplify select option construction in related dialogs while preserving the same choices.

* refactor(web): streamline pricing table rendering

- reuse translated endpoint select options between trigger data and menu items.
- precompute dynamic pricing maps per group so table cells only resolve formatted values.
- add local dynamic pricing type aliases to keep helper signatures readable.

* refactor(web): merge pricing table imports

* refactor(web): merge upstream ratio table imports

* refactor(web): merge channel selector table imports

* refactor(web): simplify tiered pricing select items

* refactor(web): reuse model ratio row state

* refactor(web): rely on table view row defaults

* refactor(web): reuse pagination state values

* refactor(web): hoist pagination size select items

* refactor(web): clarify static table body rows

* refactor(web): extract table page pagination rendering

* fix(web): remove direct hast type dependency

- rely on Shiki transformer contextual typing for line nodes.
- allow frontend typecheck to pass without an undeclared hast package.

* refactor(web): trim data table hook return API

- return only the TanStack table instance from useDataTable.
- keep internal state handling private because callers do not consume it directly.

* refactor(web): keep static table empty row private

- stop exporting the internal StaticDataTableEmptyRow helper.
- keep the public static table API focused on the table component and column type.

* refactor(web): hide data table view props from barrel

* refactor(web): remove stale long text lint override

* fix(web): keep pinned table columns opaque

- apply pinned column background classes after custom column classes.
- use an opaque hover background so scrolled content cannot show through fixed cells.

* refactor(data-table): organize shared table components

- group table primitives, page composition, toolbar controls, static tables, and hooks by responsibility.
- split shared view types, row rendering, header rendering, and pinned-column styling out of the main table view.
- keep the public data-table barrel stable while documenting the new ownership boundaries.

* fix(web): stabilize split table column sizing

- derive default colgroup widths from visible columns when split headers or header sizing are enabled.
- apply a fixed table layout with computed minimum width so header and body columns stay aligned.
- keep split-header containers from leaking horizontal overflow and avoid extra pinned-column borders.

* fix(web): set stable table utility column widths

- assign fixed widths to selection columns so shared colgroup sizing keeps checkbox cells compact.
- size id columns in redemption and user tables to keep split headers aligned with body rows.

* fix(web): align model metadata icon cells

- render compact provider avatars in the metadata icon column instead of wide wordmarks.
- position icons in a fixed-size wrapper so they line up with the existing icon header alignment.

* fix(status-badge): hide status dot by default

* fix(web): prevent user invite info overlap

- give the invite info and created-at columns explicit widths so table sizing reserves enough space.
- allow invite badges to wrap within the cell instead of spilling into adjacent columns.

* perf(data-table): cache pinned column class resolution

- reuse the pinned column lookup while table props stay stable to reduce repeated per-render work.
- share the resolved column class handler across unified and split-header table layouts.
- localize page-number screen reader labels so pagination remains accessible in every locale.

* refactor(data-table): tighten static table modes

- make StaticDataTable distinguish data-driven and children-only usage through explicit prop shapes.
- remove unsupported columns-without-data fallback after confirming no repository callers rely on it.
- default manual table modes away from unused local row models to reduce repeated table work.

* fix(data-table): make pinned edit column opaque

- use an opaque muted background for the active action column so sticky cells do not reveal scrolled content underneath.

* fix(data-table): prevent narrow column overlap

- apply stable header sizing to remaining desktop data table pages so constrained layouts scroll instead of compressing cells.
- add explicit widths for key, quota, badge, and timestamp columns that contain fixed-format content.
- constrain masked values and timestamp cells with truncation to keep content inside its assigned column.

* fix(table): align table cell content with headers

- remove extra inline padding from masked table text buttons so values start at the cell edge.
- tag status badges and offset leading badges inside table cells to match header text alignment.

* fix(table): prevent admin list column overflow

- widen redemption and subscription table columns so masked codes, timestamps, and localized headers fit.
- localize subscription ID headers and add Received amount translations across supported locales.

* fix(provider-badge): unify provider icon spacing

- add a shared provider badge component for icon and status label layout.
- reuse it in channel type and model vendor columns so OpenAI icons align consistently.
This commit is contained in:
QuentinHsu
2026-06-11 02:36:41 +08:00
committed by GitHub
parent 59a93cf5c7
commit 6f415428d3
97 changed files with 3963 additions and 3312 deletions
+1 -2
View File
@@ -27,7 +27,6 @@ import {
useEffect, useEffect,
useState, useState,
} from 'react' } from 'react'
import type { Element } from 'hast'
import { CheckIcon, CopyIcon } from 'lucide-react' import { CheckIcon, CopyIcon } from 'lucide-react'
import { import {
type BundledLanguage, type BundledLanguage,
@@ -53,7 +52,7 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
const lineNumberTransformer: ShikiTransformer = { const lineNumberTransformer: ShikiTransformer = {
name: 'line-numbers', name: 'line-numbers',
line(node: Element, line: number) { line(node, line) {
node.children.unshift({ node.children.unshift({
type: 'element', type: 'element',
tagName: 'span', tagName: 'span',
+17
View File
@@ -0,0 +1,17 @@
# Data Table Components
This package keeps a stable public API through `index.ts`; feature code should
continue importing from `@/components/data-table`.
- `core/`: TanStack table rendering primitives, headers, rows, pagination,
loading, empty states, and pinned-column behavior.
- `layout/`: responsive page-level composition that combines toolbar, desktop
table, mobile list, bulk actions, and pagination placement.
- `toolbar/`: filter/search/view-option controls and selection action toolbar.
- `static/`: lightweight table rendering for local/static arrays that do not
need TanStack state.
- `hooks/`: table state and filter hooks.
Keep feature-specific columns, actions, and dialogs inside their feature
folders. Shared table code belongs here only when it is reusable across more
than one feature.
@@ -0,0 +1,73 @@
/*
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 { cn } from '@/lib/utils'
import type { DataTableColumnClassName, DataTablePinnedColumn } from './types'
export function getResolvedColumnClassName(
getColumnClassName?: DataTableColumnClassName,
pinnedColumns?: DataTablePinnedColumn[]
): DataTableColumnClassName {
return getResolvedColumnClassNameFromMap(
getColumnClassName,
getPinnedColumnMap(pinnedColumns)
)
}
export function getResolvedColumnClassNameFromMap(
getColumnClassName?: DataTableColumnClassName,
pinnedColumnById?: Map<string, DataTablePinnedColumn>
): DataTableColumnClassName {
return (columnId, kind) => {
const customClassName = getColumnClassName?.(columnId, kind)
const pinnedColumn = pinnedColumnById?.get(columnId)
if (!pinnedColumn) return customClassName
return cn(customClassName, getPinnedColumnClassName(pinnedColumn, kind))
}
}
export function getPinnedColumnMap(pinnedColumns?: DataTablePinnedColumn[]) {
if (!pinnedColumns?.length) return undefined
return new Map(pinnedColumns.map((column) => [column.columnId, column]))
}
function getPinnedColumnClassName(
pinnedColumn: DataTablePinnedColumn,
kind: 'header' | 'cell'
) {
const edgeClassName =
pinnedColumn.side === 'left'
? 'shadow-[8px_0_10px_-10px_hsl(var(--foreground))]'
: 'shadow-[-8px_0_10px_-10px_hsl(var(--foreground))]'
return cn(
'sticky whitespace-nowrap',
pinnedColumn.side === 'left' ? 'left-0' : 'right-0',
edgeClassName,
kind === 'header'
? 'bg-background z-30'
: 'bg-background z-10 group-hover:bg-muted group-data-[state=selected]:bg-muted',
pinnedColumn.className,
kind === 'header'
? pinnedColumn.headerClassName
: pinnedColumn.cellClassName
)
}
@@ -0,0 +1,33 @@
/*
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 type { Table as TanstackTable } from '@tanstack/react-table'
export function DataTableColgroup<TData>({
table,
}: {
table: TanstackTable<TData>
}) {
return (
<colgroup>
{table.getVisibleLeafColumns().map((column) => (
<col key={column.id} style={{ width: column.getSize() }} />
))}
</colgroup>
)
}
@@ -0,0 +1,61 @@
/*
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 { flexRender, type Table as TanstackTable } from '@tanstack/react-table'
import { TableHead, TableHeader, TableRow } from '@/components/ui/table'
import type { DataTableColumnClassName } from './types'
type DataTableHeaderProps<TData> = {
table: TanstackTable<TData>
applyHeaderSize?: boolean
className?: string
rowClassName?: string
getColumnClassName?: DataTableColumnClassName
}
export function DataTableHeader<TData>({
table,
applyHeaderSize,
className,
rowClassName,
getColumnClassName,
}: DataTableHeaderProps<TData>) {
return (
<TableHeader className={className}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className={rowClassName}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={getColumnClassName?.(header.column.id, 'header')}
style={applyHeaderSize ? { width: header.getSize() } : undefined}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
)
}
@@ -0,0 +1,52 @@
/*
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 type * as React from 'react'
import { flexRender, type Row } from '@tanstack/react-table'
import { TableCell, TableRow } from '@/components/ui/table'
import type { DataTableColumnClassName } from './types'
type DataTableRowProps<TData> = {
row: Row<TData>
className?: string
getColumnClassName?: DataTableColumnClassName
} & Omit<React.ComponentProps<typeof TableRow>, 'children'>
export function DataTableRow<TData>({
row,
className,
getColumnClassName,
...rowProps
}: DataTableRowProps<TData>) {
return (
<TableRow
data-state={row.getIsSelected() ? 'selected' : undefined}
className={className}
{...rowProps}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={getColumnClassName?.(cell.column.id, 'cell')}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
@@ -0,0 +1,310 @@
/*
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 Row } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'
import {
getPinnedColumnMap,
getResolvedColumnClassNameFromMap,
} from './column-pinning'
import { DataTableColgroup } from './data-table-colgroup'
import { DataTableHeader } from './data-table-header'
import { DataTableRow } from './data-table-row'
import { TableEmpty } from './table-empty'
import { getTableSizeStyle } from './table-sizing'
import { TableSkeleton } from './table-skeleton'
import type {
DataTableColumnClassName,
DataTablePinnedColumn,
DataTableViewProps,
} from './types'
export type {
DataTableColumnClassName,
DataTablePinnedColumn,
DataTableRenderRowHelpers,
DataTableViewProps,
} from './types'
export { DataTableRow } from './data-table-row'
export function DataTableView<TData>(props: DataTableViewProps<TData>) {
const rows = props.rows ?? props.table.getRowModel().rows
const colSpan = props.table.getVisibleLeafColumns().length
const columnClassName = useResolvedColumnClassName(
props.getColumnClassName,
props.pinnedColumns
)
return (
<div
className={cn(
'overflow-hidden rounded-lg border',
props.containerClassName
)}
{...props.containerProps}
>
{props.splitHeader ? (
<SplitHeaderTableView
props={props}
rows={rows}
colSpan={colSpan}
getColumnClassName={columnClassName}
/>
) : (
<UnifiedTableView
props={props}
rows={rows}
colSpan={colSpan}
getColumnClassName={columnClassName}
/>
)}
</div>
)
}
function UnifiedTableView<TData>({
props,
rows,
colSpan,
getColumnClassName,
}: {
props: DataTableViewProps<TData>
rows: Row<TData>[]
colSpan: number
getColumnClassName: DataTableColumnClassName
}) {
const tableSizing = getTableSizing(props)
return (
<div className={props.tableContainerClassName}>
<Table className={props.tableClassName} style={tableSizing.style}>
{tableSizing.colgroup}
<DataTableHeader
table={props.table}
applyHeaderSize={props.applyHeaderSize}
className={props.tableHeaderClassName}
rowClassName={props.tableHeaderRowClassName}
getColumnClassName={getColumnClassName}
/>
{renderTableBody(props, rows, colSpan, getColumnClassName)}
</Table>
</div>
)
}
function SplitHeaderTableView<TData>({
props,
rows,
colSpan,
getColumnClassName,
}: {
props: DataTableViewProps<TData>
rows: Row<TData>[]
colSpan: number
getColumnClassName: DataTableColumnClassName
}) {
const headerHostRef = React.useRef<HTMLDivElement>(null)
const bodyHostRef = React.useRef<HTMLDivElement>(null)
const tableSizing = getTableSizing(props)
React.useEffect(() => {
const headerScroller = headerHostRef.current?.querySelector<HTMLElement>(
'[data-slot=table-container]'
)
const bodyScroller = bodyHostRef.current?.querySelector<HTMLElement>(
'[data-slot=table-container]'
)
if (!headerScroller || !bodyScroller) return
const syncHeaderScroll = () => {
headerScroller.scrollLeft = bodyScroller.scrollLeft
}
syncHeaderScroll()
bodyScroller.addEventListener('scroll', syncHeaderScroll, { passive: true })
return () => {
bodyScroller.removeEventListener('scroll', syncHeaderScroll)
}
}, [rows.length, props.tableClassName, props.colgroup])
return (
<div
className={cn(
'flex h-full min-h-0 flex-col',
props.tableContainerClassName
)}
>
<div
className={cn(
'flex min-h-0 flex-1 flex-col overflow-hidden',
props.splitHeaderScrollClassName
)}
>
<div
ref={headerHostRef}
className='[scrollbar-gutter:stable] overflow-hidden [&_[data-slot=table-container]]:overflow-x-hidden'
>
<Table className={props.tableClassName} style={tableSizing.style}>
{tableSizing.colgroup}
<DataTableHeader
table={props.table}
applyHeaderSize={props.applyHeaderSize}
className={props.tableHeaderClassName}
rowClassName={props.tableHeaderRowClassName}
getColumnClassName={getColumnClassName}
/>
</Table>
</div>
<div
ref={bodyHostRef}
className={cn(
'min-h-0 flex-1 [scrollbar-gutter:stable] overflow-y-auto',
props.bodyContainerClassName
)}
>
<Table className={props.tableClassName} style={tableSizing.style}>
{tableSizing.colgroup}
{renderTableBody(props, rows, colSpan, getColumnClassName)}
</Table>
</div>
</div>
</div>
)
}
function useResolvedColumnClassName(
getColumnClassName?: DataTableColumnClassName,
pinnedColumns?: DataTablePinnedColumn[]
) {
const pinnedColumnById = React.useMemo(
() => getPinnedColumnMap(pinnedColumns),
[pinnedColumns]
)
return React.useMemo(
() =>
getResolvedColumnClassNameFromMap(getColumnClassName, pinnedColumnById),
[getColumnClassName, pinnedColumnById]
)
}
function getTableSizing<TData>(props: DataTableViewProps<TData>): {
colgroup?: React.ReactNode
style?: React.CSSProperties
} {
if (props.colgroup) {
return { colgroup: props.colgroup }
}
if (!props.splitHeader && !props.applyHeaderSize) {
return {}
}
return {
colgroup: <DataTableColgroup table={props.table} />,
style: getTableSizeStyle(props.table),
}
}
function renderTableBody<TData>(
props: DataTableViewProps<TData>,
rows: Row<TData>[],
colSpan: number,
getColumnClassName: DataTableColumnClassName
) {
return (
<TableBody className={props.tableBodyClassName}>
{renderTableBodyContent(props, rows, colSpan, getColumnClassName)}
</TableBody>
)
}
function renderTableBodyContent<TData>(
props: DataTableViewProps<TData>,
rows: Row<TData>[],
colSpan: number,
getColumnClassName: DataTableColumnClassName
) {
if (props.isLoading) {
return (
<TableSkeleton
table={props.table}
keyPrefix={props.skeletonKeyPrefix}
rowHeight={props.skeletonRowHeight}
/>
)
}
if (rows.length === 0) {
return renderEmptyState(props, colSpan)
}
return rows.map((row) =>
props.renderRow
? props.renderRow(row, {
getCellClassName: (columnId, className) =>
cn(getColumnClassName(columnId, 'cell'), className),
})
: renderDefaultRow(props, row, getColumnClassName)
)
}
function renderEmptyState<TData>(
props: DataTableViewProps<TData>,
colSpan: number
) {
if (props.emptyContent) {
return (
<TableRow>
<TableCell colSpan={colSpan} className={props.emptyCellClassName}>
{props.emptyContent}
</TableCell>
</TableRow>
)
}
return (
<TableEmpty
colSpan={colSpan}
title={props.emptyTitle}
description={props.emptyDescription}
icon={props.emptyIcon}
>
{props.emptyAction}
</TableEmpty>
)
}
function renderDefaultRow<TData>(
props: DataTableViewProps<TData>,
row: Row<TData>,
getColumnClassName: DataTableColumnClassName
) {
return (
<DataTableRow
key={row.id}
row={row}
className={cn(props.tableBodyRowClassName, props.getRowClassName?.(row))}
getColumnClassName={getColumnClassName}
/>
)
}
@@ -39,48 +39,55 @@ type DataTablePaginationProps<TData> = {
table: Table<TData> table: Table<TData>
} }
const PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50, 100] as const
const PAGE_SIZE_SELECT_ITEMS = PAGE_SIZE_OPTIONS.map((pageSize) => ({
value: `${pageSize}`,
label: pageSize,
}))
export function DataTablePagination<TData>({ export function DataTablePagination<TData>({
table, table,
}: DataTablePaginationProps<TData>) { }: DataTablePaginationProps<TData>) {
const { t } = useTranslation() const { t } = useTranslation()
const currentPage = table.getState().pagination.pageIndex + 1 const pagination = table.getState().pagination
const currentPage = pagination.pageIndex + 1
const pageSize = pagination.pageSize
const totalPages = table.getPageCount() const totalPages = table.getPageCount()
const totalRows = table.getRowCount()
const pageNumbers = getPageNumbers(currentPage, totalPages) const pageNumbers = getPageNumbers(currentPage, totalPages)
return ( return (
<div <div
className={cn( className={cn(
'flex items-center justify-between overflow-clip', '@container/pagination flex min-w-0 items-center justify-end overflow-clip'
'@max-2xl/content:flex-col-reverse @max-2xl/content:gap-2 sm:@max-2xl/content:gap-4'
)} )}
style={{ overflowClipMargin: 1 }} style={{ overflowClipMargin: 1 }}
> >
<div className='flex w-full items-center justify-between gap-2'> <div className='flex min-w-0 shrink-0 items-center gap-2 @xl/pagination:gap-3'>
<div className='flex min-w-0 items-center text-xs font-medium whitespace-nowrap sm:min-w-[130px] sm:text-sm @2xl/content:hidden'> <div className='flex shrink-0 items-baseline gap-1.5 text-xs font-medium whitespace-nowrap sm:text-sm'>
{t('Page {{current}} of {{total}}', { <span className='text-muted-foreground/80'>{t('Total:')}</span>
current: currentPage, <span className='text-foreground tabular-nums'>
total: totalPages, {totalRows.toLocaleString()}
})} </span>
</div> </div>
<div className='flex items-center gap-2 @max-2xl/content:flex-row-reverse'>
<div className='flex shrink-0 items-center gap-1.5 @lg/pagination:gap-2'>
<p className='text-muted-foreground/80 hidden text-sm font-medium whitespace-nowrap @2xl/pagination:block'>
{t('Rows per page')}
</p>
<Select <Select
items={[ items={PAGE_SIZE_SELECT_ITEMS}
...[10, 20, 30, 40, 50, 100].map((pageSize) => ({ value={`${pageSize}`}
value: `${pageSize}`,
label: pageSize,
})),
]}
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => { onValueChange={(value) => {
table.setPageSize(Number(value)) table.setPageSize(Number(value))
}} }}
> >
<SelectTrigger className='h-8 w-[64px] sm:w-[70px]'> <SelectTrigger className='text-foreground h-8 w-[64px] font-medium tabular-nums sm:w-[70px]'>
<SelectValue placeholder={table.getState().pagination.pageSize} /> <SelectValue placeholder={pageSize} />
</SelectTrigger> </SelectTrigger>
<SelectContent side='top' alignItemWithTrigger={false}> <SelectContent side='top' alignItemWithTrigger={false}>
<SelectGroup> <SelectGroup>
{[10, 20, 30, 40, 50, 100].map((pageSize) => ( {PAGE_SIZE_OPTIONS.map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}> <SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize} {pageSize}
</SelectItem> </SelectItem>
@@ -88,23 +95,12 @@ export function DataTablePagination<TData>({
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<p className='hidden text-sm font-medium sm:block'>
{t('Rows per page')}
</p>
</div> </div>
</div>
<div className='flex items-center sm:space-x-6 lg:space-x-8'> <div className='flex min-w-0 shrink-0 items-center gap-1 @lg/pagination:gap-1.5 @xl/pagination:gap-2'>
<div className='flex min-w-[130px] items-center text-sm font-medium whitespace-nowrap @max-3xl/content:hidden'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
<div className='flex items-center space-x-1.5 sm:space-x-2'>
<Button <Button
variant='outline' variant='outline'
className='size-8 p-0 @max-md/content:hidden' className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0 @max-lg/pagination:hidden'
onClick={() => table.setPageIndex(0)} onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()} disabled={!table.getCanPreviousPage()}
> >
@@ -113,7 +109,7 @@ export function DataTablePagination<TData>({
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
className='size-8 p-0' className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0'
onClick={() => table.previousPage()} onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()} disabled={!table.getCanPreviousPage()}
> >
@@ -121,18 +117,26 @@ export function DataTablePagination<TData>({
<ChevronLeftIcon className='h-4 w-4' /> <ChevronLeftIcon className='h-4 w-4' />
</Button> </Button>
{/* Page number buttons */}
{pageNumbers.map((pageNumber, index) => ( {pageNumbers.map((pageNumber, index) => (
<div key={`${pageNumber}-${index}`} className='flex items-center'> <div key={`${pageNumber}-${index}`} className='flex items-center'>
{pageNumber === '...' ? ( {pageNumber === '...' ? (
<span className='text-muted-foreground px-1 text-sm'>...</span> <span className='text-muted-foreground/60 px-0.5 text-sm @lg/pagination:px-1'>
...
</span>
) : ( ) : (
<Button <Button
variant={currentPage === pageNumber ? 'default' : 'outline'} variant={currentPage === pageNumber ? 'default' : 'outline'}
className='h-8 min-w-8 px-2' className={cn(
'h-8 min-w-8 px-2 tabular-nums',
currentPage === pageNumber
? 'font-semibold'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => table.setPageIndex((pageNumber as number) - 1)} onClick={() => table.setPageIndex((pageNumber as number) - 1)}
> >
<span className='sr-only'>Go to page {pageNumber}</span> <span className='sr-only'>
{t('Go to page {{page}}', { page: pageNumber })}
</span>
{pageNumber} {pageNumber}
</Button> </Button>
)} )}
@@ -141,7 +145,7 @@ export function DataTablePagination<TData>({
<Button <Button
variant='outline' variant='outline'
className='size-8 p-0' className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0'
onClick={() => table.nextPage()} onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()} disabled={!table.getCanNextPage()}
> >
@@ -150,7 +154,7 @@ export function DataTablePagination<TData>({
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
className='size-8 p-0 @max-md/content:hidden' className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0 @max-lg/pagination:hidden'
onClick={() => table.setPageIndex(table.getPageCount() - 1)} onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()} disabled={!table.getCanNextPage()}
> >
@@ -0,0 +1,30 @@
/*
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 type * as React from 'react'
import type { Table as TanstackTable } from '@tanstack/react-table'
export function getTableSizeStyle<TData>(
table: TanstackTable<TData>
): React.CSSProperties {
const width = table
.getVisibleLeafColumns()
.reduce((total, column) => total + column.getSize(), 0)
return { minWidth: width, tableLayout: 'fixed', width: '100%' }
}
+71
View File
@@ -0,0 +1,71 @@
/*
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 type * as React from 'react'
import type { Row, Table as TanstackTable } from '@tanstack/react-table'
export type DataTableColumnClassName = (
columnId: string,
kind: 'header' | 'cell'
) => string | undefined
export type DataTablePinnedColumn = {
columnId: string
side: 'left' | 'right'
className?: string
headerClassName?: string
cellClassName?: string
}
export type DataTableRenderRowHelpers = {
getCellClassName: (columnId: string, className?: string) => string | undefined
}
export type DataTableViewProps<TData> = {
table: TanstackTable<TData>
isLoading?: boolean
rows?: Row<TData>[]
emptyTitle?: string
emptyDescription?: string
emptyIcon?: React.ReactNode
emptyAction?: React.ReactNode
emptyContent?: React.ReactNode
emptyCellClassName?: string
skeletonKeyPrefix?: string
skeletonRowHeight?: string
renderRow?: (
row: Row<TData>,
helpers: DataTableRenderRowHelpers
) => React.ReactNode
getRowClassName?: (row: Row<TData>) => string | undefined
getColumnClassName?: DataTableColumnClassName
pinnedColumns?: DataTablePinnedColumn[]
applyHeaderSize?: boolean
tableClassName?: string
tableHeaderClassName?: string
tableHeaderRowClassName?: string
tableBodyClassName?: string
tableBodyRowClassName?: string
splitHeader?: boolean
splitHeaderScrollClassName?: string
bodyContainerClassName?: string
containerClassName?: string
containerProps?: Omit<React.ComponentProps<'div'>, 'className' | 'children'>
tableContainerClassName?: string
colgroup?: React.ReactNode
}
@@ -0,0 +1,234 @@
/*
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 ColumnDef,
type ColumnFiltersState,
type ExpandedState,
type OnChangeFn,
type PaginationState,
type RowSelectionState,
type SortingState,
type TableOptions,
type Updater,
type VisibilityState,
getCoreRowModel,
getExpandedRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
type DataTableFeatureOptions<TData> = Pick<
TableOptions<TData>,
| 'enableRowSelection'
| 'getRowId'
| 'getSubRows'
| 'globalFilterFn'
| 'autoResetPageIndex'
| 'manualFiltering'
| 'manualPagination'
| 'manualSorting'
>
type DataTableStateOptions = {
initialSorting?: SortingState
sorting?: SortingState
onSortingChange?: OnChangeFn<SortingState>
initialColumnVisibility?: VisibilityState
columnVisibility?: VisibilityState
onColumnVisibilityChange?: OnChangeFn<VisibilityState>
initialRowSelection?: RowSelectionState
rowSelection?: RowSelectionState
onRowSelectionChange?: OnChangeFn<RowSelectionState>
initialExpanded?: ExpandedState
expanded?: ExpandedState
onExpandedChange?: OnChangeFn<ExpandedState>
columnFilters?: ColumnFiltersState
onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>
globalFilter?: string
onGlobalFilterChange?: OnChangeFn<string>
initialPagination?: PaginationState
pagination?: PaginationState
onPaginationChange?: OnChangeFn<PaginationState>
}
type DataTableRowModelOptions = {
withFilteredRowModel?: boolean
withPaginationRowModel?: boolean
withSortedRowModel?: boolean
withFacetedRowModel?: boolean
withExpandedRowModel?: boolean
}
type UseDataTableOptions<TData> = DataTableFeatureOptions<TData> &
DataTableStateOptions &
DataTableRowModelOptions & {
data: TData[]
columns: ColumnDef<TData, unknown>[]
totalCount?: number
pageCount?: number
ensurePageInRange?: (pageCount: number) => void
}
function resolveUpdater<TValue>(
updater: Updater<TValue>,
previous: TValue
): TValue {
return typeof updater === 'function'
? (updater as (old: TValue) => TValue)(previous)
: updater
}
function useControllableTableState<TValue>(
controlledValue: TValue | undefined,
defaultValue: TValue,
onChange: OnChangeFn<TValue> | undefined
): [TValue, OnChangeFn<TValue>] {
const [uncontrolledValue, setUncontrolledValue] =
React.useState<TValue>(defaultValue)
const value = controlledValue ?? uncontrolledValue
const setValue = React.useCallback<OnChangeFn<TValue>>(
(updater) => {
if (controlledValue === undefined) {
setUncontrolledValue((previous) => resolveUpdater(updater, previous))
}
onChange?.(updater)
},
[controlledValue, onChange]
)
return [value, setValue]
}
export function useDataTable<TData>(options: UseDataTableOptions<TData>) {
const {
data,
columns,
totalCount,
pageCount: explicitPageCount,
ensurePageInRange,
manualFiltering,
manualPagination,
manualSorting,
initialSorting = [],
initialColumnVisibility = {},
initialRowSelection = {},
initialExpanded = {},
initialPagination = { pageIndex: 0, pageSize: 20 },
withFilteredRowModel = !manualFiltering,
withPaginationRowModel = !manualPagination,
withSortedRowModel = !manualSorting,
withFacetedRowModel = !manualFiltering,
withExpandedRowModel = false,
} = options
const [sorting, onSortingChange] = useControllableTableState(
options.sorting,
initialSorting,
options.onSortingChange
)
const [columnVisibility, onColumnVisibilityChange] =
useControllableTableState(
options.columnVisibility,
initialColumnVisibility,
options.onColumnVisibilityChange
)
const [rowSelection, onRowSelectionChange] = useControllableTableState(
options.rowSelection,
initialRowSelection,
options.onRowSelectionChange
)
const [expanded, onExpandedChange] = useControllableTableState(
options.expanded,
initialExpanded,
options.onExpandedChange
)
const [pagination, onPaginationChange] = useControllableTableState(
options.pagination,
initialPagination,
options.onPaginationChange
)
const resolvedPageCount =
explicitPageCount ??
(totalCount !== undefined
? Math.ceil(totalCount / pagination.pageSize)
: undefined)
const table = useReactTable({
data,
columns,
rowCount: totalCount,
pageCount: resolvedPageCount,
state: {
sorting,
columnVisibility,
rowSelection,
expanded,
columnFilters: options.columnFilters,
globalFilter: options.globalFilter,
pagination,
},
enableRowSelection: options.enableRowSelection,
getRowId: options.getRowId,
getSubRows: options.getSubRows,
globalFilterFn: options.globalFilterFn,
autoResetPageIndex: options.autoResetPageIndex,
manualFiltering,
manualPagination,
manualSorting,
onSortingChange,
onColumnVisibilityChange,
onRowSelectionChange,
onExpandedChange,
onColumnFiltersChange: options.onColumnFiltersChange,
onGlobalFilterChange: options.onGlobalFilterChange,
onPaginationChange,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: withFilteredRowModel
? getFilteredRowModel()
: undefined,
getPaginationRowModel: withPaginationRowModel
? getPaginationRowModel()
: undefined,
getSortedRowModel: withSortedRowModel ? getSortedRowModel() : undefined,
getFacetedRowModel: withFacetedRowModel ? getFacetedRowModel() : undefined,
getFacetedUniqueValues: withFacetedRowModel
? getFacetedUniqueValues()
: undefined,
getExpandedRowModel: withExpandedRowModel
? getExpandedRowModel()
: undefined,
})
const actualPageCount = table.getPageCount()
React.useEffect(() => {
ensurePageInRange?.(actualPageCount)
}, [actualPageCount, ensurePageInRange])
return {
table,
}
}
@@ -0,0 +1,110 @@
/*
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 [pendingValue, setPendingValue] = React.useState(value)
const isComposingRef = React.useRef(false)
const debouncedValue = useDebounce(pendingValue, delay)
React.useEffect(() => {
// Keep the input aligned when URL state changes outside the local field.
if (!isComposingRef.current) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setInputValue(value)
}
// eslint-disable-next-line react-hooks/set-state-in-effect
setPendingValue(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])
const updateInputValue = React.useCallback((nextValue: string) => {
setInputValue(nextValue)
if (!isComposingRef.current) {
setPendingValue(nextValue)
}
}, [])
const handleChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
updateInputValue(event.target.value)
},
[updateInputValue]
)
const handleCompositionStart = React.useCallback(() => {
isComposingRef.current = true
}, [])
const handleCompositionEnd = React.useCallback(
(event: React.CompositionEvent<HTMLInputElement>) => {
isComposingRef.current = false
const nextValue = event.currentTarget.value
setInputValue(nextValue)
setPendingValue(nextValue)
},
[]
)
const resetInput = React.useCallback(() => {
isComposingRef.current = false
setInputValue('')
setPendingValue('')
}, [])
return {
value,
inputValue,
setInputValue: updateInputValue,
onChange: handleChange,
onCompositionStart: handleCompositionStart,
onCompositionEnd: handleCompositionEnd,
resetInput,
}
}
+24 -10
View File
@@ -16,16 +16,30 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
export { DataTablePagination } from './pagination' export { DataTablePagination } from './core/pagination'
export { DataTableColumnHeader } from './column-header' export { DataTableColumnHeader } from './core/column-header'
export { DataTableFacetedFilter } from './faceted-filter' export { DataTableViewOptions } from './toolbar/view-options'
export { DataTableViewOptions } from './view-options' export { DataTableToolbar } from './toolbar/toolbar'
export { DataTableToolbar } from './toolbar' export { DataTableBulkActions } from './toolbar/bulk-actions'
export { DataTableBulkActions } from './bulk-actions' export {
export { TableSkeleton } from './table-skeleton' StaticDataTable,
export { TableEmpty } from './table-empty' type StaticDataTableColumn,
export { MobileCardList } from './mobile-card-list' } from './static/static-data-table'
export { DataTablePage, type DataTablePageProps } from './data-table-page' export { staticDataTableClassNames } from './static/static-data-table-classnames'
export {
DataTableRow,
DataTableView,
type DataTableColumnClassName,
type DataTablePinnedColumn,
type DataTableRenderRowHelpers,
} from './core/data-table-view'
export { MobileCardList } from './layout/mobile-card-list'
export {
DataTablePage,
type DataTablePageProps,
} from './layout/data-table-page'
export { useDataTable } from './hooks/use-data-table'
export { useDebouncedColumnFilter } from './hooks/use-debounced-column-filter'
export const DISABLED_ROW_DESKTOP = 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' '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'
@@ -18,27 +18,22 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import * as React from 'react' import * as React from 'react'
import { import {
flexRender,
type ColumnDef, type ColumnDef,
type Row, type Row,
type Table as TanstackTable, type Table as TanstackTable,
} from '@tanstack/react-table' } from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks' import { useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { PageFooterPortal } from '@/components/layout' import { PageFooterPortal } from '@/components/layout'
import {
DataTableView,
type DataTableColumnClassName,
type DataTablePinnedColumn,
type DataTableRenderRowHelpers,
} from '../core/data-table-view'
import { DataTablePagination } from '../core/pagination'
import { DataTableToolbar } from '../toolbar/toolbar'
import { MobileCardList } from './mobile-card-list' import { MobileCardList } from './mobile-card-list'
import { DataTablePagination } from './pagination'
import { TableEmpty } from './table-empty'
import { TableSkeleton } from './table-skeleton'
import { DataTableToolbar } from './toolbar'
/** /**
* Pass-through configuration for the default {@link DataTableToolbar}. * Pass-through configuration for the default {@link DataTableToolbar}.
@@ -145,7 +140,22 @@ export type DataTablePageProps<TData> = {
* Custom desktop row renderer replaces the default `<TableRow>`/`<TableCell>` mapping. * Custom desktop row renderer replaces the default `<TableRow>`/`<TableCell>` mapping.
* Use for expanded rows, aggregate rows, click-on-row navigation, etc. * Use for expanded rows, aggregate rows, click-on-row navigation, etc.
*/ */
renderRow?: (row: Row<TData>) => React.ReactNode renderRow?: (
row: Row<TData>,
helpers: DataTableRenderRowHelpers
) => React.ReactNode
/**
* Desktop column className resolver. Use for semantic alignment/spacing only;
* fixed-column behavior should be configured with `pinnedColumns`.
*/
getColumnClassName?: DataTableColumnClassName
/**
* Fixed desktop columns. The shared table component owns sticky position,
* layering, shadows, and row-state backgrounds.
*/
pinnedColumns?: DataTablePinnedColumn[]
/** /**
* Apply explicit column widths from `header.getSize()` to `<TableHead>`. * Apply explicit column widths from `header.getSize()` to `<TableHead>`.
@@ -182,6 +192,12 @@ export type DataTablePageProps<TData> = {
*/ */
className?: string className?: string
/**
* Make the desktop table consume the available page height and scroll inside
* the table body while keeping the header fixed. Defaults to `true`.
*/
fixedHeight?: boolean
/** /**
* Desktop table container className (the bordered scroll wrapper). * Desktop table container className (the bordered scroll wrapper).
*/ */
@@ -189,7 +205,8 @@ export type DataTablePageProps<TData> = {
/** /**
* Desktop `<TableHeader>` className override. * Desktop `<TableHeader>` className override.
* Useful for sticky headers (`'sticky top-0 z-10 bg-muted/30'`) on long lists. * Use for header color/spacing overrides. Fixed-height pages keep the header
* outside the scrollable body automatically.
*/ */
tableHeaderClassName?: string tableHeaderClassName?: string
} }
@@ -222,10 +239,18 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
const toolbarNode = renderToolbar(props) const toolbarNode = renderToolbar(props)
const mobileNode = renderMobile(props, showMobile) const mobileNode = renderMobile(props, showMobile)
const desktopNode = renderDesktop(props, showMobile) const desktopNode = renderDesktop(props, showMobile)
const paginationNode = renderPagination(props)
return ( return (
<> <>
<div className={cn('space-y-2.5 sm:space-y-3', props.className)}> <div
className={cn(
props.fixedHeight !== false
? 'flex h-full min-h-0 flex-col gap-2.5 sm:gap-3'
: 'space-y-2.5 sm:space-y-3',
props.className
)}
>
{toolbarNode} {toolbarNode}
{mobileNode} {mobileNode}
{desktopNode} {desktopNode}
@@ -236,16 +261,7 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
handle its own visibility, we just gate it to non-mobile. */} handle its own visibility, we just gate it to non-mobile. */}
{!showMobile && props.bulkActions} {!showMobile && props.bulkActions}
{props.showPagination !== false && {paginationNode}
(props.paginationInFooter !== false ? (
<PageFooterPortal>
<DataTablePagination table={props.table} />
</PageFooterPortal>
) : (
<div className='pt-2'>
<DataTablePagination table={props.table} />
</div>
))}
</> </>
) )
} }
@@ -265,12 +281,25 @@ function renderToolbar<TData>(
return null return null
} }
function renderPagination<TData>(
props: DataTablePageProps<TData>
): React.ReactNode {
if (props.showPagination === false) return null
const pagination = <DataTablePagination table={props.table} />
return props.paginationInFooter !== false ? (
<PageFooterPortal>{pagination}</PageFooterPortal>
) : (
<div className='pt-2'>{pagination}</div>
)
}
function renderMobile<TData>( function renderMobile<TData>(
props: DataTablePageProps<TData>, props: DataTablePageProps<TData>,
showMobile: boolean showMobile: boolean
): React.ReactNode { ): React.ReactNode {
if (!showMobile) return null if (!showMobile) return null
if (props.mobile !== undefined) return props.mobile
const ownGetRowClassName = props.getRowClassName const ownGetRowClassName = props.getRowClassName
const mobileGetRowClassName = const mobileGetRowClassName =
@@ -278,8 +307,7 @@ function renderMobile<TData>(
(ownGetRowClassName (ownGetRowClassName
? (row: Row<TData>) => ownGetRowClassName(row, { isMobile: true }) ? (row: Row<TData>) => ownGetRowClassName(row, { isMobile: true })
: undefined) : undefined)
const mobileContent = props.mobile ?? (
return (
<MobileCardList <MobileCardList
table={props.table} table={props.table}
isLoading={props.isLoading} isLoading={props.isLoading}
@@ -289,6 +317,8 @@ function renderMobile<TData>(
getRowClassName={mobileGetRowClassName} getRowClassName={mobileGetRowClassName}
/> />
) )
return <div className='min-h-0 flex-1 overflow-y-auto'>{mobileContent}</div>
} }
function renderDesktop<TData>( function renderDesktop<TData>(
@@ -297,94 +327,37 @@ function renderDesktop<TData>(
): React.ReactNode { ): React.ReactNode {
if (showMobile) return null if (showMobile) return null
const rows = props.table.getRowModel().rows
const isFetchingOnly = props.isFetching && !props.isLoading const isFetchingOnly = props.isFetching && !props.isLoading
const fixedHeight = props.fixedHeight !== false
return ( return (
<div <DataTableView
className={cn( table={props.table}
'overflow-hidden rounded-lg border transition-opacity duration-150', isLoading={props.isLoading}
emptyTitle={props.emptyTitle}
emptyDescription={props.emptyDescription}
emptyIcon={props.emptyIcon}
emptyAction={props.emptyAction}
skeletonKeyPrefix={props.skeletonKeyPrefix}
renderRow={props.renderRow}
applyHeaderSize={props.applyHeaderSize}
splitHeader={fixedHeight}
tableContainerClassName={fixedHeight ? 'h-full min-h-0' : undefined}
tableHeaderClassName={cn(
fixedHeight && 'bg-muted/30',
props.tableHeaderClassName
)}
getColumnClassName={props.getColumnClassName}
pinnedColumns={props.pinnedColumns}
containerClassName={cn(
fixedHeight && 'min-h-0 flex-1',
'transition-opacity duration-150',
isFetchingOnly && 'pointer-events-none opacity-60', isFetchingOnly && 'pointer-events-none opacity-60',
props.tableClassName props.tableClassName
)} )}
> getRowClassName={(row) =>
<Table> props.getRowClassName?.(row, { isMobile: false })
<TableHeader className={props.tableHeaderClassName}> }
{props.table.getHeaderGroups().map((headerGroup) => ( />
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
colSpan={header.colSpan}
style={
props.applyHeaderSize
? { width: header.getSize() }
: undefined
}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{props.isLoading ? (
<TableSkeleton
table={props.table}
keyPrefix={props.skeletonKeyPrefix}
/>
) : rows.length === 0 ? (
<TableEmpty
colSpan={props.columns.length}
title={props.emptyTitle}
description={props.emptyDescription}
icon={props.emptyIcon}
>
{props.emptyAction}
</TableEmpty>
) : (
rows.map((row) => {
if (props.renderRow) {
return props.renderRow(row)
}
return (
<DefaultRow
key={row.id}
row={row}
className={props.getRowClassName?.(row, { isMobile: false })}
/>
)
})
)}
</TableBody>
</Table>
</div>
)
}
function DefaultRow<TData>({
row,
className,
}: {
row: Row<TData>
className?: string
}) {
return (
<TableRow
data-state={row.getIsSelected() && 'selected'}
className={className}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
) )
} }
@@ -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
@@ -0,0 +1,206 @@
/*
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 { cn } from '@/lib/utils'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { staticDataTableClassNames } from './static-data-table-classnames'
type StaticDataTableBaseProps = {
className?: string
tableClassName?: string
containerProps?: Omit<React.ComponentProps<'div'>, 'className' | 'children'>
tableProps?: Omit<
React.ComponentProps<typeof Table>,
'className' | 'children'
>
}
type StaticDataTableDataProps<TData = unknown> = StaticDataTableBaseProps & {
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
}
type StaticDataTableChildrenProps = StaticDataTableBaseProps & {
children: React.ReactNode
columns?: never
data?: never
}
type StaticDataTableProps<TData = unknown> =
| StaticDataTableDataProps<TData>
| StaticDataTableChildrenProps
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>(
props: StaticDataTableProps<TData>
) {
const { className, tableClassName, containerProps, tableProps } = props
return (
<div
className={cn(staticDataTableClassNames.container, className)}
{...containerProps}
>
<Table className={tableClassName} {...tableProps}>
{props.columns !== undefined ? (
<StaticDataTableWithColumns {...props} />
) : (
props.children
)}
</Table>
</div>
)
}
function StaticDataTableWithColumns<TData>({
columns,
data,
getRowKey,
getRowClassName,
renderRow,
empty,
emptyContent,
emptyClassName,
headerRowClassName,
}: StaticDataTableDataProps<TData>) {
const isEmpty = empty ?? (data !== undefined && data.length === 0)
const bodyRows = data.map((row, index) => (
<StaticDataTableRow
key={getRowKey?.(row, index) ?? index}
row={row}
index={index}
columns={columns}
getRowClassName={getRowClassName}
renderRow={renderRow}
/>
))
return (
<>
<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>
) : (
bodyRows
)}
</TableBody>
</>
)
}
type StaticDataTableRowProps<TData> = Required<
Pick<StaticDataTableDataProps<TData>, 'columns'>
> &
Pick<StaticDataTableDataProps<TData>, 'getRowClassName' | 'renderRow'> & {
row: TData
index: number
}
function StaticDataTableRow<TData>({
row,
index,
columns,
getRowClassName,
renderRow,
}: StaticDataTableRowProps<TData>) {
if (renderRow) {
return <>{renderRow(row, index)}</>
}
return (
<TableRow className={getRowClassName?.(row, index)}>
{columns.map((column) => (
<TableCell
key={column.id}
className={getStaticCellClassName(column, row, index)}
>
{column.cell?.(row, index)}
</TableCell>
))}
</TableRow>
)
}
function getStaticCellClassName<TData>(
column: StaticDataTableColumn<TData>,
row: TData,
index: number
) {
return typeof column.cellClassName === 'function'
? column.cellClassName(row, index)
: column.cellClassName
}
type StaticDataTableEmptyRowProps = {
colSpan: number
children: React.ReactNode
className?: string
}
function StaticDataTableEmptyRow({
colSpan,
children,
className,
}: StaticDataTableEmptyRowProps) {
return (
<TableRow>
<TableCell
colSpan={colSpan}
className={cn('h-24 text-center', className)}
>
{children}
</TableCell>
</TableRow>
)
}
@@ -50,6 +50,7 @@ SectionPageLayoutBreadcrumb.displayName = 'SectionPageLayout.Breadcrumb'
export type SectionPageLayoutProps = { export type SectionPageLayoutProps = {
children: ReactNode children: ReactNode
fixedContent?: boolean
} }
export function SectionPageLayout(props: SectionPageLayoutProps) { export function SectionPageLayout(props: SectionPageLayoutProps) {
@@ -95,7 +96,13 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
</div> </div>
</div> </div>
<div className='min-h-0 flex-1 overflow-auto px-3 pt-1 pb-3 sm:px-4 sm:pt-1.5 sm:pb-4'> <div
className={
props.fixedContent
? 'min-h-0 flex-1 overflow-hidden px-3 pt-1 pb-3 sm:px-4 sm:pt-1.5 sm:pb-4'
: 'min-h-0 flex-1 overflow-auto px-3 pt-1 pb-3 sm:px-4 sm:pt-1.5 sm:pb-4'
}
>
{content} {content}
</div> </div>
-1
View File
@@ -46,7 +46,6 @@ export function LongText({
useEffect(() => { useEffect(() => {
if (checkOverflow(ref.current)) { if (checkOverflow(ref.current)) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsOverflown(true) setIsOverflown(true)
return return
} }
+7 -3
View File
@@ -42,14 +42,18 @@ interface MaskedValueDisplayProps {
*/ */
export function MaskedValueDisplay(props: MaskedValueDisplayProps) { export function MaskedValueDisplay(props: MaskedValueDisplayProps) {
return ( return (
<div className='flex items-center'> <div className='flex max-w-full min-w-0 items-center'>
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
render={ render={
<Button variant='ghost' size='sm' className='h-7 font-mono' /> <Button
variant='ghost'
size='sm'
className='h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono hover:bg-transparent aria-expanded:bg-transparent'
/>
} }
> >
{props.maskedValue} <span className='truncate'>{props.maskedValue}</span>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className='w-auto max-w-[min(90vw,28rem)]' className='w-auto max-w-[min(90vw,28rem)]'
+44
View File
@@ -0,0 +1,44 @@
/*
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 { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
import { StatusBadge, type StatusBadgeProps } from './status-badge'
type ProviderBadgeProps = Omit<StatusBadgeProps, 'children' | 'label'> & {
iconKey?: string | null
iconSize?: number
label: string
}
export function ProviderBadge({
className,
iconKey,
iconSize = 14,
label,
...badgeProps
}: ProviderBadgeProps) {
const icon = iconKey ? getLobeIcon(iconKey, iconSize) : null
return (
<div className={cn('flex items-center gap-1.5', className)}>
{icon}
<StatusBadge label={label} autoColor={label} size='sm' {...badgeProps} />
</div>
)
}
+2 -1
View File
@@ -103,7 +103,7 @@ export function StatusBadge({
variant, variant,
size = 'sm', size = 'sm',
pulse = false, pulse = false,
showDot = true, showDot = false,
copyable = true, copyable = true,
copyText, copyText,
autoColor, autoColor,
@@ -130,6 +130,7 @@ export function StatusBadge({
return ( return (
<span <span
data-slot='status-badge'
className={cn( className={cn(
'inline-flex w-fit max-w-full shrink-0 items-center rounded-4xl font-medium tracking-normal whitespace-nowrap transition-colors', 'inline-flex w-fit max-w-full shrink-0 items-center rounded-4xl font-medium tracking-normal whitespace-nowrap transition-colors',
sizeMap[size ?? 'sm'], sizeMap[size ?? 'sm'],
+1 -1
View File
@@ -103,7 +103,7 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
<td <td
data-slot='table-cell' data-slot='table-cell'
className={cn( className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0', 'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>*:has(>[data-slot=status-badge]:first-child):first-child]:-ml-1.5 [&>[data-slot=status-badge]:first-child]:-ml-1.5',
className className
)} )}
{...props} {...props}
@@ -35,7 +35,6 @@ import {
formatTimestampToDate, formatTimestampToDate,
formatQuota as formatQuotaValue, formatQuota as formatQuotaValue,
} from '@/lib/format' } from '@/lib/format'
import { getLobeIcon } from '@/lib/lobe-icon'
import { truncateText } from '@/lib/utils' import { truncateText } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
@@ -46,8 +45,9 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataTableColumnHeader } from '@/components/data-table/column-header' import { DataTableColumnHeader } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge' import { GroupBadge } from '@/components/group-badge'
import { ProviderBadge } from '@/components/provider-badge'
import { StatusBadge, StatusBadgeList } from '@/components/status-badge' import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
import { TableId } from '@/components/table-id' import { TableId } from '@/components/table-id'
import { TruncatedText } from '@/components/truncated-text' import { TruncatedText } from '@/components/truncated-text'
@@ -623,7 +623,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
const typeNameKey = getChannelTypeLabel(type) const typeNameKey = getChannelTypeLabel(type)
const typeName = t(typeNameKey) const typeName = t(typeNameKey)
const iconName = getChannelTypeIcon(type) const iconName = getChannelTypeIcon(type)
const icon = getLobeIcon(`${iconName}.Color`, 14)
const channel = row.original as Channel const channel = row.original as Channel
const isMultiKey = isMultiKeyChannel(channel) const isMultiKey = isMultiKeyChannel(channel)
const multiKeyMode = channel.channel_info?.multi_key_mode ?? 'random' const multiKeyMode = channel.channel_info?.multi_key_mode ?? 'random'
@@ -657,16 +656,12 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)} )}
<StatusBadge <ProviderBadge
autoColor={typeName} iconKey={iconName}
size='sm' label={typeName}
copyable={false} copyable={false}
showDot={false} showDot={false}
className='gap-1 pl-1' />
>
{icon}
<span className='truncate'>{typeName}</span>
</StatusBadge>
{isIonet && ( {isIonet && (
<TooltipProvider delay={100}> <TooltipProvider delay={100}>
<Tooltip> <Tooltip>
@@ -16,27 +16,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { import { useState, useMemo } from 'react'
useState,
useMemo,
useEffect,
useRef,
type ChangeEvent,
type CompositionEvent,
} from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router' import { getRouteApi } from '@tanstack/react-router'
import { import {
getCoreRowModel,
useReactTable,
getExpandedRowModel,
type OnChangeFn, type OnChangeFn,
type SortingState, type SortingState,
type VisibilityState,
type ExpandedState,
type Row, type Row,
} from '@tanstack/react-table' } from '@tanstack/react-table'
import { useDebounce, useMediaQuery } from '@/hooks' import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon' import { getLobeIcon } from '@/lib/lobe-icon'
import { useTableUrlState } from '@/hooks/use-table-url-state' import { useTableUrlState } from '@/hooks/use-table-url-state'
@@ -45,6 +33,8 @@ import {
DISABLED_ROW_DESKTOP, DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE, DISABLED_ROW_MOBILE,
DataTablePage, DataTablePage,
useDebouncedColumnFilter,
useDataTable,
} from '@/components/data-table' } from '@/components/data-table'
import { getChannels, searchChannels, getGroups } from '../api' import { getChannels, searchChannels, getGroups } from '../api'
import { import {
@@ -88,12 +78,6 @@ export function ChannelsTable() {
// Table state // Table state
const [sorting, setSorting] = useState<SortingState>([]) const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
models: false,
tag: false,
})
const [rowSelection, setRowSelection] = useState({})
const [expanded, setExpanded] = useState<ExpandedState>({})
// URL state management // URL state management
const { const {
@@ -123,71 +107,24 @@ export function ChannelsTable() {
// Extract filters from column filters // Extract filters from column filters
const statusFilter = const statusFilter =
(columnFilters.find((f) => f.id === 'status')?.value as string[]) || [] (columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
const typeFilter = const typeFilter = useMemo(
(columnFilters.find((f) => f.id === 'type')?.value as string[]) || [] () => (columnFilters.find((f) => f.id === 'type')?.value as string[]) || [],
[columnFilters]
)
const groupFilter = const groupFilter =
(columnFilters.find((f) => f.id === 'group')?.value as string[]) || [] (columnFilters.find((f) => f.id === 'group')?.value as string[]) || []
const modelFilterFromUrl = const {
(columnFilters.find((f) => f.id === 'model')?.value as string) || '' value: modelFilter,
inputValue: modelFilterInput,
// Local state for immediate input feedback onChange: onModelFilterInputChange,
const isModelFilterComposingRef = useRef(false) onCompositionStart: onModelFilterCompositionStart,
const [modelFilterInput, setModelFilterInput] = useState(modelFilterFromUrl) onCompositionEnd: onModelFilterCompositionEnd,
const [modelFilterPendingValue, setModelFilterPendingValue] = resetInput: resetModelFilterInput,
useState(modelFilterFromUrl) } = useDebouncedColumnFilter({
const debouncedModelFilter = useDebounce(modelFilterPendingValue, 500) columnFilters,
columnId: 'model',
// Sync local input with URL when URL changes (e.g., from back/forward navigation)
useEffect(() => {
if (!isModelFilterComposingRef.current) {
setModelFilterInput(modelFilterFromUrl)
}
setModelFilterPendingValue(modelFilterFromUrl)
}, [modelFilterFromUrl])
// Update URL when debounced value changes
useEffect(() => {
if (
debouncedModelFilter === modelFilterPendingValue &&
debouncedModelFilter !== modelFilterFromUrl
) {
onColumnFiltersChange((prev) => {
const filtered = prev.filter((f) => f.id !== 'model')
return debouncedModelFilter
? [...filtered, { id: 'model', value: debouncedModelFilter }]
: filtered
})
}
}, [
debouncedModelFilter,
modelFilterFromUrl,
modelFilterPendingValue,
onColumnFiltersChange, onColumnFiltersChange,
]) })
const handleModelFilterChange = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
setModelFilterInput(value)
if (!isModelFilterComposingRef.current) {
setModelFilterPendingValue(value)
}
}
const handleModelFilterCompositionStart = () => {
isModelFilterComposingRef.current = true
}
const handleModelFilterCompositionEnd = (
event: CompositionEvent<HTMLInputElement>
) => {
isModelFilterComposingRef.current = false
const value = event.currentTarget.value
setModelFilterInput(value)
setModelFilterPendingValue(value)
}
const modelFilter = modelFilterFromUrl
// Determine whether to use search or regular list API // Determine whether to use search or regular list API
const shouldSearch = Boolean(globalFilter?.trim() || modelFilter.trim()) const shouldSearch = Boolean(globalFilter?.trim() || modelFilter.trim())
@@ -322,41 +259,31 @@ export function ChannelsTable() {
const columns = useChannelsColumns() const columns = useChannelsColumns()
// React Table instance // React Table instance
const table = useReactTable({ const { table } = useDataTable({
data: channels, data: channels,
columns, columns,
pageCount: Math.ceil(totalCount / pagination.pageSize), totalCount,
state: { sorting,
sorting, initialColumnVisibility: {
columnFilters, models: false,
columnVisibility, tag: false,
rowSelection,
pagination,
expanded,
globalFilter,
}, },
columnFilters,
pagination,
globalFilter,
enableRowSelection: (row: Row<Channel>) => !isTagAggregateRow(row.original), enableRowSelection: (row: Row<Channel>) => !isTagAggregateRow(row.original),
onRowSelectionChange: setRowSelection,
onSortingChange: handleSortingChange, onSortingChange: handleSortingChange,
onColumnFiltersChange, onColumnFiltersChange,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange, onPaginationChange,
onExpandedChange: setExpanded,
onGlobalFilterChange, onGlobalFilterChange,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (row: Channel & { children?: Channel[] }) => row.children, getSubRows: (row: Channel & { children?: Channel[] }) => row.children,
manualPagination: true, manualPagination: true,
manualSorting: true, manualSorting: true,
manualFiltering: true, manualFiltering: true,
withExpandedRowModel: true,
ensurePageInRange,
}) })
// Ensure page is in range when total count changes
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [pageCount, ensurePageInRange])
// Prepare filter options from existing channel types only. // Prepare filter options from existing channel types only.
const typeFilterOptions = useMemo(() => { const typeFilterOptions = useMemo(() => {
const counts = typeCounts || {} const counts = typeCounts || {}
@@ -430,17 +357,15 @@ export function ChannelsTable() {
searchPlaceholder: t('Filter by name, ID, or key...'), searchPlaceholder: t('Filter by name, ID, or key...'),
searchDebounceMs: 500, searchDebounceMs: 500,
onReset: () => { onReset: () => {
isModelFilterComposingRef.current = false resetModelFilterInput()
setModelFilterInput('')
setModelFilterPendingValue('')
}, },
additionalSearch: ( additionalSearch: (
<Input <Input
placeholder={t('Filter by model...')} placeholder={t('Filter by model...')}
value={modelFilterInput} value={modelFilterInput}
onChange={handleModelFilterChange} onChange={onModelFilterInputChange}
onCompositionStart={handleModelFilterCompositionStart} onCompositionStart={onModelFilterCompositionStart}
onCompositionEnd={handleModelFilterCompositionEnd} onCompositionEnd={onModelFilterCompositionEnd}
className='w-full sm:w-[150px] lg:w-[180px]' className='w-full sm:w-[150px] lg:w-[180px]'
/> />
), ),
@@ -21,10 +21,6 @@ import {
type ColumnDef, type ColumnDef,
type RowSelectionState, type RowSelectionState,
type Table as TanStackTable, type Table as TanStackTable,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table' } from '@tanstack/react-table'
import { Check, Copy, Info, Loader2, Settings } from 'lucide-react' import { Check, Copy, Info, Loader2, Settings } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -52,21 +48,17 @@ import {
SheetTitle, SheetTitle,
} from '@/components/ui/sheet' } from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table' import {
import { DataTablePagination } from '@/components/data-table/pagination' DataTableBulkActions as BulkActionsToolbar,
DataTablePagination,
DataTableView,
useDataTable,
} from '@/components/data-table'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
import { import {
sideDrawerContentClassName, sideDrawerContentClassName,
@@ -200,7 +192,7 @@ function getTestTableColumnClass(columnId: string) {
case 'status': case 'status':
return 'w-70 min-w-70 max-w-70 whitespace-normal' return 'w-70 min-w-70 max-w-70 whitespace-normal'
case 'actions': case 'actions':
return 'bg-popover sticky right-0 z-20 w-24 min-w-24 border-l shadow-[-8px_0_8px_-8px_rgb(0_0_0_/_0.2)] whitespace-nowrap sm:w-28 sm:min-w-28' return 'bg-popover w-24 min-w-24 whitespace-nowrap sm:w-28 sm:min-w-28'
default: default:
return undefined return undefined
} }
@@ -227,6 +219,14 @@ export function ChannelTestDialog({
pageIndex: 0, pageIndex: 0,
pageSize: 10, pageSize: 10,
}) })
const endpointSelectItems = useMemo(
() =>
endpointTypeOptions.map((option) => ({
value: option.value,
label: t(option.label),
})),
[t]
)
const resetState = useCallback(() => { const resetState = useCallback(() => {
setEndpointType('auto') setEndpointType('auto')
@@ -502,18 +502,17 @@ export function ChannelTestDialog({
] ]
) )
const table = useReactTable({ const { table } = useDataTable({
data: tableData, data: tableData,
columns, columns,
state: { rowSelection,
rowSelection, pagination,
pagination,
},
enableRowSelection: true, enableRowSelection: true,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination, onPaginationChange: setPagination,
withFilteredRowModel: false,
withSortedRowModel: false,
withFacetedRowModel: false,
}) })
if (!currentRow) { if (!currentRow) {
@@ -548,12 +547,7 @@ export function ChannelTestDialog({
<div className='grid gap-2'> <div className='grid gap-2'>
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label> <Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
<Select <Select
items={[ items={endpointSelectItems}
...endpointTypeOptions.map((option) => {
const itemValue = option.value
return { value: itemValue, label: t(option.label) }
}),
]}
value={endpointType} value={endpointType}
onValueChange={(v) => v !== null && setEndpointType(v)} onValueChange={(v) => v !== null && setEndpointType(v)}
> >
@@ -562,14 +556,11 @@ export function ChannelTestDialog({
</SelectTrigger> </SelectTrigger>
<SelectContent alignItemWithTrigger={false}> <SelectContent alignItemWithTrigger={false}>
<SelectGroup> <SelectGroup>
{endpointTypeOptions.map((option) => { {endpointSelectItems.map((option) => (
const itemValue = option.value <SelectItem key={option.value} value={option.value}>
return ( {option.label}
<SelectItem key={itemValue} value={itemValue}> </SelectItem>
{t(option.label)} ))}
</SelectItem>
)
})}
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -615,80 +606,41 @@ export function ChannelTestDialog({
</div> </div>
<div className='space-y-3'> <div className='space-y-3'>
<div <DataTableView
className='overflow-hidden rounded-md border' table={table}
role='region' containerClassName='rounded-md'
aria-label={t('Channel models')} containerProps={{
> role: 'region',
<div className='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'> 'aria-label': t('Channel models'),
<Table className='w-max min-w-full table-auto'> }}
<colgroup> tableContainerClassName='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'
<col className='w-10 min-w-10' /> tableClassName='w-max min-w-full table-auto'
<col className='w-auto' /> pinnedColumns={[
<col className='w-70' /> {
<col className='w-24 sm:w-28' /> columnId: 'actions',
</colgroup> side: 'right',
<TableHeader> className: 'w-24 min-w-24 sm:w-28 sm:min-w-28',
{table.getHeaderGroups().map((headerGroup) => ( cellClassName: 'bg-popover',
<TableRow key={headerGroup.id}> },
{headerGroup.headers.map((header) => ( ]}
<TableHead colgroup={
key={header.id} <colgroup>
className={getTestTableColumnClass( <col className='w-10 min-w-10' />
header.column.id <col className='w-auto' />
)} <col className='w-70' />
> <col className='w-24 sm:w-28' />
{header.isPlaceholder </colgroup>
? null }
: flexRender( getColumnClassName={(columnId) =>
header.column.columnDef.header, getTestTableColumnClass(columnId)
header.getContext() }
)} emptyContent={
</TableHead> models.length
))} ? t('No models matched your search.')
</TableRow> : t('This channel has no configured models.')
))} }
</TableHeader> emptyCellClassName='text-muted-foreground h-16 text-center text-sm'
<TableBody> />
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() ? 'selected' : undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={getTestTableColumnClass(
cell.column.id
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className='text-muted-foreground h-16 text-center text-sm'
>
{models.length
? 'No models matched your search.'
: 'This channel has no configured models.'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTablePagination table={table} /> <DataTablePagination table={table} />
</div> </div>
@@ -31,15 +31,8 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { import {
@@ -358,48 +351,53 @@ export function MultiKeyManageDialog({
{t('No keys found')} {t('No keys found')}
</div> </div>
) : ( ) : (
<div className='min-w-[800px]'> <StaticDataTable
<Table> className='rounded-none border-0'
<TableHeader> tableClassName='min-w-[800px]'
<TableRow> data={keys}
<TableHead className='w-20'>{t('Index')}</TableHead> getRowKey={(key) => key.index}
<TableHead className='w-32'>{t('Status')}</TableHead> columns={[
<TableHead className='min-w-[200px]'> {
{t('Disabled Reason')} id: 'index',
</TableHead> header: t('Index'),
<TableHead className='w-44'> className: 'w-20',
{t('Disabled Time')} cellClassName: 'font-mono text-sm',
</TableHead> cell: (key) => `#${key.index + 1}`,
<TableHead className='w-44 text-right'> },
{t('Actions')} {
</TableHead> id: 'status',
</TableRow> header: t('Status'),
</TableHeader> className: 'w-32',
<TableBody> cell: (key) => renderStatusBadge(key.status),
{keys.map((key) => ( },
<TableRow key={key.index}> {
<TableCell className='font-mono text-sm'> id: 'reason',
#{key.index + 1} header: t('Disabled Reason'),
</TableCell> className: 'min-w-[200px]',
<TableCell>{renderStatusBadge(key.status)}</TableCell> cellClassName: 'max-w-xs truncate text-sm',
<TableCell className='max-w-xs truncate text-sm'> cell: (key) => key.reason || '-',
{key.reason || '-'} },
</TableCell> {
<TableCell className='text-muted-foreground text-sm'> id: 'disabled-time',
{formatKeyTimestamp(key.disabled_time)} header: t('Disabled Time'),
</TableCell> className: 'w-44',
<TableCell> cellClassName: 'text-muted-foreground text-sm',
<MultiKeyTableRowActions cell: (key) => formatKeyTimestamp(key.disabled_time),
keyIndex={key.index} },
status={key.status} {
onAction={setConfirmAction} id: 'actions',
/> header: t('Actions'),
</TableCell> className: 'w-44 text-right',
</TableRow> cell: (key) => (
))} <MultiKeyTableRowActions
</TableBody> keyIndex={key.index}
</Table> status={key.status}
</div> onAction={setConfirmAction}
/>
),
},
]}
/>
)} )}
</div> </div>
+1 -1
View File
@@ -27,7 +27,7 @@ export function Channels() {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<ChannelsProvider> <ChannelsProvider>
<SectionPageLayout> <SectionPageLayout fixedContent>
<SectionPageLayout.Title>{t('Channels')}</SectionPageLayout.Title> <SectionPageLayout.Title>{t('Channels')}</SectionPageLayout.Title>
<SectionPageLayout.Actions> <SectionPageLayout.Actions>
<ChannelsPrimaryButtons /> <ChannelsPrimaryButtons />
@@ -76,18 +76,18 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
}, [resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied, t]) }, [resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied, t])
return ( return (
<div className='flex items-center'> <div className='flex max-w-full min-w-0 items-center'>
<Popover open={popoverOpen} onOpenChange={handlePopoverOpen}> <Popover open={popoverOpen} onOpenChange={handlePopoverOpen}>
<PopoverTrigger <PopoverTrigger
render={ render={
<Button <Button
variant='ghost' variant='ghost'
size='sm' size='sm'
className='text-muted-foreground h-7 font-mono text-xs' className='text-muted-foreground h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono text-xs hover:bg-transparent aria-expanded:bg-transparent'
/> />
} }
> >
{maskedKey} <span className='truncate'>{maskedKey}</span>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className='w-auto max-w-[min(90vw,28rem)]' className='w-auto max-w-[min(90vw,28rem)]'
@@ -92,6 +92,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
), ),
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
size: 40,
meta: { label: t('Select') }, meta: { label: t('Select') },
}, },
{ {
@@ -104,6 +105,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
{row.getValue('name')} {row.getValue('name')}
</div> </div>
), ),
size: 180,
meta: { label: t('Name'), mobileTitle: true }, meta: { label: t('Name'), mobileTitle: true },
}, },
{ {
@@ -123,6 +125,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
) )
}, },
filterFn: (row, id, value) => value.includes(String(row.getValue(id))), filterFn: (row, id, value) => value.includes(String(row.getValue(id))),
size: 120,
meta: { label: t('Status'), mobileBadge: true }, meta: { label: t('Status'), mobileBadge: true },
}, },
{ {
@@ -131,6 +134,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
header: t('API Key'), header: t('API Key'),
cell: ({ row }) => <ApiKeyCell apiKey={row.original} />, cell: ({ row }) => <ApiKeyCell apiKey={row.original} />,
enableSorting: false, enableSorting: false,
size: 260,
meta: { label: t('API Key') }, meta: { label: t('API Key') },
}, },
{ {
@@ -189,6 +193,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
</Tooltip> </Tooltip>
) )
}, },
size: 170,
meta: { label: t('Quota') }, meta: { label: t('Quota') },
}, },
{ {
@@ -230,6 +235,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
} }
return <GroupBadge group={group} ratio={ratio} /> return <GroupBadge group={group} ratio={ratio} />
}, },
size: 160,
meta: { label: t('Group'), mobileHidden: true }, meta: { label: t('Group'), mobileHidden: true },
}, },
{ {
@@ -240,6 +246,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
), ),
cell: ({ row }) => <ModelLimitsCell apiKey={row.original} />, cell: ({ row }) => <ModelLimitsCell apiKey={row.original} />,
enableSorting: false, enableSorting: false,
size: 160,
meta: { label: t('Models'), mobileHidden: true }, meta: { label: t('Models'), mobileHidden: true },
}, },
{ {
@@ -250,6 +257,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
), ),
cell: ({ row }) => <IpRestrictionsCell apiKey={row.original} />, cell: ({ row }) => <IpRestrictionsCell apiKey={row.original} />,
enableSorting: false, enableSorting: false,
size: 160,
meta: { label: t('IP Restriction'), mobileHidden: true }, meta: { label: t('IP Restriction'), mobileHidden: true },
}, },
{ {
@@ -258,10 +266,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
<DataTableColumnHeader column={column} title={t('Created')} /> <DataTableColumnHeader column={column} title={t('Created')} />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span className='text-muted-foreground font-mono text-xs tabular-nums'> <span className='text-muted-foreground block truncate font-mono text-xs tabular-nums'>
{formatTimestampToDate(row.getValue('created_time'))} {formatTimestampToDate(row.getValue('created_time'))}
</span> </span>
), ),
size: 180,
meta: { label: t('Created'), mobileHidden: true }, meta: { label: t('Created'), mobileHidden: true },
}, },
{ {
@@ -275,11 +284,12 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
return <span className='text-muted-foreground text-xs'>-</span> return <span className='text-muted-foreground text-xs'>-</span>
} }
return ( return (
<span className='text-muted-foreground font-mono text-xs tabular-nums'> <span className='text-muted-foreground block truncate font-mono text-xs tabular-nums'>
{formatTimestampToDate(accessedTime)} {formatTimestampToDate(accessedTime)}
</span> </span>
) )
}, },
size: 180,
meta: { label: t('Last Used'), mobileHidden: true }, meta: { label: t('Last Used'), mobileHidden: true },
}, },
{ {
@@ -302,7 +312,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
return ( return (
<span <span
className={cn( className={cn(
'font-mono text-xs tabular-nums', 'block truncate font-mono text-xs tabular-nums',
isExpired ? 'text-destructive' : 'text-muted-foreground' isExpired ? 'text-destructive' : 'text-muted-foreground'
)} )}
> >
@@ -310,6 +320,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
</span> </span>
) )
}, },
size: 180,
meta: { label: t('Expires'), mobileHidden: true }, meta: { label: t('Expires'), mobileHidden: true },
}, },
{ {
+20 -62
View File
@@ -16,21 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router' import { getRouteApi } from '@tanstack/react-router'
import { import { type Table as TanstackTable } from '@tanstack/react-table'
type SortingState,
type VisibilityState,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { useDebounce } from '@/hooks'
import { Database } from 'lucide-react' import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -50,6 +38,8 @@ import {
DISABLED_ROW_DESKTOP, DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE, DISABLED_ROW_MOBILE,
DataTablePage, DataTablePage,
useDebouncedColumnFilter,
useDataTable,
} from '@/components/data-table' } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { getApiKeys, searchApiKeys } from '../api' import { getApiKeys, searchApiKeys } from '../api'
@@ -99,7 +89,7 @@ function ApiKeysMobileList({
table, table,
isLoading, isLoading,
}: { }: {
table: ReturnType<typeof useReactTable<ApiKey>> table: TanstackTable<ApiKey>
isLoading: boolean isLoading: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
@@ -192,9 +182,6 @@ export function ApiKeysTable() {
const { t } = useTranslation() const { t } = useTranslation()
const { refreshTrigger } = useApiKeys() const { refreshTrigger } = useApiKeys()
const columns = useApiKeysColumns() const columns = useApiKeysColumns()
const [rowSelection, setRowSelection] = useState({})
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const { const {
globalFilter, globalFilter,
@@ -215,27 +202,15 @@ export function ApiKeysTable() {
], ],
}) })
const tokenFilterFromUrl = const {
(columnFilters.find((f) => f.id === '_tokenSearch')?.value as string) || '' value: tokenFilter,
const [tokenFilterInput, setTokenFilterInput] = useState(tokenFilterFromUrl) inputValue: tokenFilterInput,
const debouncedTokenFilter = useDebounce(tokenFilterInput, 500) setInputValue: setTokenFilterInput,
} = useDebouncedColumnFilter({
useEffect(() => { columnFilters,
setTokenFilterInput(tokenFilterFromUrl) columnId: '_tokenSearch',
}, [tokenFilterFromUrl]) onColumnFiltersChange,
})
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 shouldSearch = Boolean(globalFilter?.trim() || tokenFilter.trim()) const shouldSearch = Boolean(globalFilter?.trim() || tokenFilter.trim())
// Fetch data with React Query // Fetch data with React Query
@@ -284,40 +259,22 @@ export function ApiKeysTable() {
const apiKeys = data?.items || [] const apiKeys = data?.items || []
const table = useReactTable({ const { table } = useDataTable({
data: apiKeys, data: apiKeys,
columns, columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
globalFilter,
pagination,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, columnFilters,
onSortingChange: setSorting, globalFilter,
onColumnVisibilityChange: setColumnVisibility, pagination,
globalFilterFn: () => true, globalFilterFn: () => true,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
onPaginationChange, onPaginationChange,
onGlobalFilterChange, onGlobalFilterChange,
onColumnFiltersChange, onColumnFiltersChange,
manualPagination: true, manualPagination: true,
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize), totalCount: data?.total || 0,
ensurePageInRange,
}) })
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [pageCount, ensurePageInRange])
return ( return (
<DataTablePage <DataTablePage
table={table} table={table}
@@ -329,6 +286,7 @@ export function ApiKeysTable() {
'No API keys available. Create your first API key to get started.' 'No API keys available. Create your first API key to get started.'
)} )}
skeletonKeyPrefix='api-keys-skeleton' skeletonKeyPrefix='api-keys-skeleton'
applyHeaderSize
toolbarProps={{ toolbarProps={{
searchPlaceholder: t('Filter by name...'), searchPlaceholder: t('Filter by name...'),
additionalSearch: ( additionalSearch: (
+1 -1
View File
@@ -27,7 +27,7 @@ export function ApiKeys() {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<ApiKeysProvider> <ApiKeysProvider>
<SectionPageLayout> <SectionPageLayout fixedContent>
<SectionPageLayout.Title>{t('API Keys')}</SectionPageLayout.Title> <SectionPageLayout.Title>{t('API Keys')}</SectionPageLayout.Title>
<SectionPageLayout.Actions> <SectionPageLayout.Actions>
<ApiKeysPrimaryButtons /> <ApiKeysPrimaryButtons />
@@ -21,7 +21,7 @@ import { Eye, Info, Pencil, Settings2, Timer, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { formatTimestampToDate } from '@/lib/format' import { formatTimestampToDate } from '@/lib/format'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { DataTableColumnHeader } from '@/components/data-table/column-header' import { DataTableColumnHeader } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id' import { TableId } from '@/components/table-id'
import { getDeploymentStatusConfig } from '../constants' import { getDeploymentStatusConfig } from '../constants'
@@ -16,14 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useEffect, useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router' import { getRouteApi } from '@tanstack/react-router'
import {
getCoreRowModel,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks' import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -38,7 +33,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { DataTablePage } from '@/components/data-table' import { DataTablePage, useDataTable } from '@/components/data-table'
import { deleteDeployment, listDeployments, searchDeployments } from '../api' import { deleteDeployment, listDeployments, searchDeployments } from '../api'
import { getDeploymentStatusOptions } from '../constants' import { getDeploymentStatusOptions } from '../constants'
import { deploymentsQueryKeys } from '../lib' import { deploymentsQueryKeys } from '../lib'
@@ -167,8 +162,6 @@ export function DeploymentsTable() {
} }
} }
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const columns = useDeploymentsColumns({ const columns = useDeploymentsColumns({
onViewLogs: (id) => { onViewLogs: (id) => {
setLogsDeploymentId(id) setLogsDeploymentId(id)
@@ -197,30 +190,22 @@ export function DeploymentsTable() {
}, },
}) })
const table = useReactTable({ const { table } = useDataTable({
data: deployments, data: deployments,
columns, columns,
pageCount: Math.ceil(totalCount / pagination.pageSize), totalCount,
state: { columnFilters,
columnFilters, pagination,
columnVisibility, globalFilter,
pagination,
globalFilter,
},
onColumnFiltersChange, onColumnFiltersChange,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange, onPaginationChange,
onGlobalFilterChange, onGlobalFilterChange,
getCoreRowModel: getCoreRowModel(),
manualPagination: true, manualPagination: true,
manualFiltering: true, manualFiltering: true,
withSortedRowModel: false,
ensurePageInRange,
}) })
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [ensurePageInRange, pageCount])
const statusFilterOptions = useMemo(() => { const statusFilterOptions = useMemo(() => {
return [...getDeploymentStatusOptions(t)].map((opt) => ({ return [...getDeploymentStatusOptions(t)].map((opt) => ({
label: opt.label, label: opt.label,
@@ -46,15 +46,8 @@ import {
EmptyMedia, EmptyMedia,
EmptyTitle, EmptyTitle,
} from '@/components/ui/empty' } from '@/components/ui/empty'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id' import { TableId } from '@/components/table-id'
@@ -344,110 +337,117 @@ export function PrefillGroupManagementDialog({
))} ))}
</div> </div>
) : ( ) : (
<div className='rounded-md border'> <StaticDataTable
<div className='w-full overflow-x-auto'> tableClassName='min-w-[680px]'
<Table className='min-w-[680px]'> data={normalizedGroups}
<TableHeader> getRowKey={({ group }) => group.id}
<TableRow> columns={[
<TableHead>{t('Group')}</TableHead> {
<TableHead>{t('Type')}</TableHead> id: 'group',
<TableHead className='min-w-[240px]'> header: t('Group'),
{t('Items')} cellClassName: 'align-top whitespace-normal',
</TableHead> cell: ({ group }) => (
<TableHead className='w-[120px] text-right'> <div className='flex flex-col gap-1'>
{t('Actions')} <div className='flex flex-wrap items-center gap-2'>
</TableHead> <span className='font-medium'>{group.name}</span>
</TableRow> <TableId value={group.id} />
</TableHeader> </div>
<TableBody> {group.description ? (
{normalizedGroups.map(({ group, meta, parsedItems }) => ( <p className='text-muted-foreground text-xs'>
<TableRow key={group.id}> {group.description}
<TableCell className='align-top whitespace-normal'> </p>
<div className='flex flex-col gap-1'> ) : (
<div className='flex flex-wrap items-center gap-2'> <p className='text-muted-foreground text-xs italic'>
<span className='font-medium'>{group.name}</span> No description provided
<TableId value={group.id} /> </p>
</div> )}
{group.description ? ( </div>
<p className='text-muted-foreground text-xs'> ),
{group.description} },
</p> {
) : ( id: 'type',
<p className='text-muted-foreground text-xs italic'> header: t('Type'),
No description provided cellClassName: 'align-top',
</p> 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 ? (
<>
{parsedItems.slice(0, 6).map((item) => (
<StatusBadge
key={item}
label={item}
autoColor={item}
size='sm'
/>
))}
{parsedItems.length > 6 && (
<StatusBadge
label={`+${parsedItems.length - 6} more`}
variant='neutral'
size='sm'
copyable={false}
/>
)} )}
</div> </>
</TableCell> ) : (
<TableCell className='align-top'> <p className='text-muted-foreground text-sm'>
<StatusBadge {group.type === 'endpoint'
label={meta.label} ? 'No endpoint mappings configured.'
variant={meta.badge} : 'No items configured yet.'}
size='sm' </p>
copyable={false} )}
/> </div>
</TableCell> <div className='text-muted-foreground mt-2 text-xs font-medium tracking-wide uppercase'>
<TableCell className='align-top whitespace-normal'> {parsedItems.length} item
<div className='flex flex-wrap gap-2'> {parsedItems.length === 1 ? '' : 's'}
{parsedItems.length > 0 ? ( </div>
<> </>
{parsedItems.slice(0, 6).map((item) => ( ),
<StatusBadge },
key={item} {
label={item} id: 'actions',
autoColor={item} header: t('Actions'),
size='sm' className: 'w-[120px] text-right',
/> cellClassName: 'align-top',
))} cell: ({ group }) => (
{parsedItems.length > 6 && ( <div className='flex justify-end gap-2'>
<StatusBadge <Button
label={`+${parsedItems.length - 6} more`} size='icon'
variant='neutral' variant='outline'
size='sm' onClick={() => onEditGroup(group)}
copyable={false} >
/> <Pencil className='h-4 w-4' />
)} <span className='sr-only'>Edit group</span>
</> </Button>
) : ( <Button
<p className='text-muted-foreground text-sm'> size='icon'
{group.type === 'endpoint' variant='ghost'
? 'No endpoint mappings configured.' className='text-destructive hover:text-destructive'
: 'No items configured yet.'} onClick={() => handleDeleteClick(group)}
</p> >
)} <Trash2 className='h-4 w-4' />
</div> <span className='sr-only'>Delete group</span>
<div className='text-muted-foreground mt-2 text-xs font-medium tracking-wide uppercase'> </Button>
{parsedItems.length} item </div>
{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>
</Table>
</div>
</div>
)} )}
</div> </div>
</Dialog> </Dialog>
@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { useEffect, useMemo, useState, useCallback } from 'react' import { useEffect, useMemo, useState, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { import { type ColumnDef, type RowSelectionState } from '@tanstack/react-table'
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
type RowSelectionState,
} from '@tanstack/react-table'
import { import {
Search, Search,
Info, Info,
@@ -51,14 +45,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { import { DataTableView, useDataTable } from '@/components/data-table'
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { applyUpstreamOverwrite } from '../../api' import { applyUpstreamOverwrite } from '../../api'
@@ -78,6 +65,8 @@ const FIELD_LABELS: Record<string, string> = {
enable_groups: 'Enable Groups', enable_groups: 'Enable Groups',
} }
const PAGE_SIZE_OPTIONS = [5, 10, 20, 50] as const
const formatValue = (value: unknown) => { const formatValue = (value: unknown) => {
if (value === null || value === undefined) return '—' if (value === null || value === undefined) return '—'
if (typeof value === 'string') return value || '—' if (typeof value === 'string') return value || '—'
@@ -341,16 +330,17 @@ export function UpstreamConflictDialog({
] ]
}, [isMobile]) }, [isMobile])
const table = useReactTable({ const { table } = useDataTable({
data: conflictRows, data: conflictRows,
columns, columns,
state: { rowSelection,
rowSelection,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id, getRowId: (row) => row.id,
withFilteredRowModel: false,
withPaginationRowModel: false,
withSortedRowModel: false,
withFacetedRowModel: false,
}) })
const totalSelectedFields = table.getSelectedRowModel().rows.length const totalSelectedFields = table.getSelectedRowModel().rows.length
@@ -536,43 +526,14 @@ export function UpstreamConflictDialog({
) : ( ) : (
<div className='flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border'> <div className='flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border'>
<div className='flex-1 overflow-auto'> <div className='flex-1 overflow-auto'>
<div className={isMobile ? 'min-w-full' : 'min-w-[720px]'}> <DataTableView
<Table> table={table}
<TableHeader> rows={paginatedRows}
{table.getHeaderGroups().map((headerGroup) => ( containerClassName='border-0'
<TableRow key={headerGroup.id}> tableContainerClassName={
{headerGroup.headers.map((header) => ( isMobile ? 'min-w-full' : 'min-w-[720px]'
<TableHead key={header.id}> }
{header.isPlaceholder />
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{paginatedRows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div> </div>
<div className='bg-muted/40 flex flex-col gap-2 border-t px-2 py-1.5 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-3 sm:py-2'> <div className='bg-muted/40 flex flex-col gap-2 border-t px-2 py-1.5 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-3 sm:py-2'>
@@ -587,12 +548,10 @@ export function UpstreamConflictDialog({
{t('Rows per page')} {t('Rows per page')}
</span> </span>
<Select <Select
items={[ items={PAGE_SIZE_OPTIONS.map((size) => ({
...[5, 10, 20, 50].map((size) => ({ value: String(size),
value: String(size), label: size,
label: size, }))}
})),
]}
value={String(pageSize)} value={String(pageSize)}
onValueChange={(value) => { onValueChange={(value) => {
setPageSize(Number(value)) setPageSize(Number(value))
@@ -604,7 +563,7 @@ export function UpstreamConflictDialog({
</SelectTrigger> </SelectTrigger>
<SelectContent alignItemWithTrigger={false}> <SelectContent alignItemWithTrigger={false}>
<SelectGroup> <SelectGroup>
{[5, 10, 20, 50].map((size) => ( {PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={String(size)}> <SelectItem key={size} value={String(size)}>
{size} {size}
</SelectItem> </SelectItem>
+15 -15
View File
@@ -27,8 +27,9 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { DataTableColumnHeader } from '@/components/data-table/column-header' import { DataTableColumnHeader } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge' import { GroupBadge } from '@/components/group-badge'
import { ProviderBadge } from '@/components/provider-badge'
import { StatusBadge, StatusBadgeList } from '@/components/status-badge' import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
import { TableId } from '@/components/table-id' import { TableId } from '@/components/table-id'
import { import {
@@ -41,6 +42,12 @@ import type { Model, Vendor } from '../types'
import { DataTableRowActions } from './data-table-row-actions' import { DataTableRowActions } from './data-table-row-actions'
import { DescriptionCell } from './description-cell' import { DescriptionCell } from './description-cell'
function getCompactModelIcon(iconKey: string) {
const baseIconKey = iconKey.split('.')[0]
return getLobeIcon(`${baseIconKey}.Avatar.type={'platform'}`, 20)
}
/** /**
* Render limited items with "and X more" indicator * Render limited items with "and X more" indicator
*/ */
@@ -123,9 +130,13 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
vendorMap[model.vendor_id || 0]?.icon || vendorMap[model.vendor_id || 0]?.icon ||
model.model_name?.[0] || model.model_name?.[0] ||
'N' 'N'
const icon = getLobeIcon(iconKey, 20) const icon = getCompactModelIcon(iconKey)
return <div className='flex items-center justify-center'>{icon}</div> return (
<div className='ms-1 flex size-5 items-center justify-center overflow-hidden'>
{icon}
</div>
)
}, },
size: 70, size: 70,
enableSorting: false, enableSorting: false,
@@ -259,18 +270,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
return <span className='text-muted-foreground text-xs'>-</span> return <span className='text-muted-foreground text-xs'>-</span>
} }
const icon = vendor.icon ? getLobeIcon(vendor.icon, 14) : null return <ProviderBadge iconKey={vendor.icon} label={vendor.name} />
return (
<div className='flex items-center gap-1.5'>
{icon}
<StatusBadge
label={vendor.name}
autoColor={vendor.name}
size='sm'
/>
</div>
)
}, },
filterFn: (row, id, value) => { filterFn: (row, id, value) => {
if (!value || value.length === 0 || value.includes('all')) return true if (!value || value.length === 0 || value.includes('all')) return true
+12 -36
View File
@@ -16,19 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useState, useMemo, useEffect } from 'react' import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router' import { getRouteApi } from '@tanstack/react-router'
import {
getCoreRowModel,
useReactTable,
type SortingState,
type VisibilityState,
} from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks' import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useTableUrlState } from '@/hooks/use-table-url-state' import { useTableUrlState } from '@/hooks/use-table-url-state'
import { DataTablePage } from '@/components/data-table' import { DataTablePage, useDataTable } from '@/components/data-table'
import { getModels, searchModels, getVendors } from '../api' import { getModels, searchModels, getVendors } from '../api'
import { import {
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
@@ -47,15 +41,6 @@ export function ModelsTable() {
const { selectedVendor } = useModels() const { selectedVendor } = useModels()
const isMobile = useMediaQuery('(max-width: 640px)') const isMobile = useMediaQuery('(max-width: 640px)')
// Table state
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
description: false,
bound_channels: false,
quota_types: false,
})
const [rowSelection, setRowSelection] = useState({})
// URL state management // URL state management
const { const {
globalFilter, globalFilter,
@@ -176,37 +161,28 @@ export function ModelsTable() {
const columns = useModelsColumns(vendors) const columns = useModelsColumns(vendors)
// React Table instance // React Table instance
const table = useReactTable({ const { table } = useDataTable({
data: models, data: models,
columns, columns,
pageCount: Math.ceil(totalCount / pagination.pageSize), totalCount,
state: { initialColumnVisibility: {
sorting, description: false,
columnFilters, bound_channels: false,
columnVisibility, quota_types: false,
rowSelection,
pagination,
globalFilter,
}, },
columnFilters,
pagination,
globalFilter,
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange, onColumnFiltersChange,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange, onPaginationChange,
onGlobalFilterChange, onGlobalFilterChange,
getCoreRowModel: getCoreRowModel(),
manualPagination: true, manualPagination: true,
manualSorting: true, manualSorting: true,
manualFiltering: true, manualFiltering: true,
ensurePageInRange,
}) })
// Ensure page is in range when total count changes
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [pageCount, ensurePageInRange])
// Prepare filter options // Prepare filter options
const vendorFilterOptions = [ const vendorFilterOptions = [
{ {
+19 -17
View File
@@ -119,7 +119,7 @@ function ModelsContent() {
return ( return (
<> <>
<SectionPageLayout> <SectionPageLayout fixedContent>
<SectionPageLayout.Title>{t(meta.titleKey)}</SectionPageLayout.Title> <SectionPageLayout.Title>{t(meta.titleKey)}</SectionPageLayout.Title>
<SectionPageLayout.Actions> <SectionPageLayout.Actions>
{activeSection === 'metadata' ? ( {activeSection === 'metadata' ? (
@@ -132,7 +132,7 @@ function ModelsContent() {
)} )}
</SectionPageLayout.Actions> </SectionPageLayout.Actions>
<SectionPageLayout.Content> <SectionPageLayout.Content>
<div className='space-y-4'> <div className='flex h-full min-h-0 flex-col gap-4'>
<Tabs value={activeSection} onValueChange={handleSectionChange}> <Tabs value={activeSection} onValueChange={handleSectionChange}>
<TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'> <TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'>
{MODELS_SECTION_IDS.map((section) => ( {MODELS_SECTION_IDS.map((section) => (
@@ -142,21 +142,23 @@ function ModelsContent() {
))} ))}
</TabsList> </TabsList>
</Tabs> </Tabs>
{activeSection === 'metadata' ? ( <div className='min-h-0 flex-1'>
<ModelsTable /> {activeSection === 'metadata' ? (
) : ( <ModelsTable />
<DeploymentAccessGuard ) : (
loading={deploymentLoading} <DeploymentAccessGuard
loadingPhase={loadingPhase} loading={deploymentLoading}
isEnabled={isIoNetEnabled} loadingPhase={loadingPhase}
connectionLoading={connectionLoading} isEnabled={isIoNetEnabled}
connectionOk={connectionOk} connectionLoading={connectionLoading}
connectionError={connectionError} connectionOk={connectionOk}
onRetry={testConnection} connectionError={connectionError}
> onRetry={testConnection}
<DeploymentsTable /> >
</DeploymentAccessGuard> <DeploymentsTable />
)} </DeploymentAccessGuard>
)}
</div>
</div> </div>
</SectionPageLayout.Content> </SectionPageLayout.Content>
</SectionPageLayout> </SectionPageLayout>
@@ -22,14 +22,7 @@ import { useTranslation } from 'react-i18next'
import { useSystemConfigStore } from '@/stores/system-config-store' import { useSystemConfigStore } from '@/stores/system-config-store'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { import { StaticDataTable } from '@/components/data-table'
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { import {
BILLING_PRICING_VARS, BILLING_PRICING_VARS,
MATCH_CONTAINS, MATCH_CONTAINS,
@@ -307,86 +300,82 @@ export function DynamicPricingBreakdown({
) )
})} })}
</div> </div>
<div className='hidden overflow-x-auto sm:block'> <StaticDataTable
<Table className='text-sm'> className='hidden rounded-none border-0 sm:block'
<TableHeader> tableClassName='text-sm'
<TableRow className='hover:bg-transparent'> headerRowClassName='hover:bg-transparent'
<TableHead className='text-muted-foreground py-2 font-medium'> data={tiers}
{t('Tier')} getRowKey={(_tier, index) => `tier-${index}`}
</TableHead> getRowClassName={(tier) => {
{visiblePriceFields.map((v) => ( const isMatched =
<TableHead normalizedMatchedTierLabel !== '' &&
key={v.field} normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
className='text-muted-foreground py-2 text-right font-medium' return cn(
> isMatched &&
{t(v.shortLabel)} 'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10'
</TableHead> )
))} }}
</TableRow> columns={[
</TableHeader> {
<TableBody> id: 'tier',
{tiers.map((tier, i) => { header: t('Tier'),
const condSummary = formatConditionSummary(tier.conditions, t) className: 'text-muted-foreground py-2 font-medium',
cellClassName: 'py-2.5 align-top',
cell: (tier) => {
const condSummary = formatConditionSummary(
tier.conditions,
t
)
const isMatched = const isMatched =
normalizedMatchedTierLabel !== '' && normalizedMatchedTierLabel !== '' &&
normalizeTierLabel(tier.label) === normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
normalizedMatchedTierLabel
return ( return (
<TableRow <>
key={`tier-${i}`} <div className='flex flex-wrap items-center gap-1.5'>
className={cn( <Badge
isMatched && variant='secondary'
'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10' className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
)} >
> {tier.label || t('Default')}
<TableCell className='py-2.5 align-top'> </Badge>
<div className='flex flex-wrap items-center gap-1.5'> {isMatched && (
<Badge <Badge
variant='secondary' variant='secondary'
className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' className='bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
> >
{tier.label || t('Default')} {t('Matched')}
</Badge> </Badge>
{isMatched && (
<Badge
variant='secondary'
className='bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
>
{t('Matched')}
</Badge>
)}
</div>
{condSummary && (
<div className='text-muted-foreground mt-1 text-xs'>
{condSummary}
</div>
)} )}
</TableCell> </div>
{visiblePriceFields.map((v) => { {condSummary && (
const value = Number( <div className='text-muted-foreground mt-1 text-xs'>
tier[v.field as string as keyof ParsedTier] || 0 {condSummary}
) </div>
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> },
</Table> ...visiblePriceFields.map((v, index) => ({
</div> 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> </div>
)} )}
@@ -32,19 +32,15 @@ import type { BundledLanguage } from 'shiki/bundle/web'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status' import { useStatus } from '@/hooks/use-status'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { import {
CodeBlock, CodeBlock,
CodeBlockCopyButton, CodeBlockCopyButton,
} from '@/components/ai-elements/code-block' } from '@/components/ai-elements/code-block'
import {
StaticDataTable,
staticDataTableClassNames as tableStyles,
} from '@/components/data-table'
import { import {
buildRateLimits, buildRateLimits,
buildSupportedParameters, buildSupportedParameters,
@@ -570,53 +566,62 @@ function SupportedParametersSection(props: { model: PricingModel }) {
return ( return (
<section> <section>
<SectionTitle icon={Sigma}>{t('Supported parameters')}</SectionTitle> <SectionTitle icon={Sigma}>{t('Supported parameters')}</SectionTitle>
<div className='border-border/60 overflow-hidden rounded-lg border'> <StaticDataTable
<Table> className={tableStyles.sectionContainer}
<TableHeader> headerRowClassName={tableStyles.mutedHeaderRow}
<TableRow className='bg-muted/30 hover:bg-muted/30'> data={params}
<TableHead className='h-9 w-44'>{t('Parameter')}</TableHead> getRowKey={(param) => param.name}
<TableHead className='h-9 w-24'>{t('Type')}</TableHead> getRowClassName={() => 'hover:bg-muted/20'}
<TableHead className='h-9 w-32'>{t('Default / range')}</TableHead> columns={[
<TableHead className='h-9'>{t('Description')}</TableHead> {
</TableRow> id: 'parameter',
</TableHeader> header: t('Parameter'),
<TableBody> className: 'h-9 w-44',
{params.map((p) => ( cellClassName: tableStyles.topCell,
<TableRow key={p.name} className='hover:bg-muted/20'> cell: (p) => (
<TableCell className='py-2 align-top'> <div className='flex items-center gap-1.5'>
<div className='flex items-center gap-1.5'> <code className='font-mono text-sm font-medium'>{p.name}</code>
<code className='font-mono text-sm font-medium'> {p.required && (
{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='py-2 align-top'>
<Badge <Badge
variant='secondary' variant='outline'
className='h-7 rounded-full px-2.5 font-mono text-sm font-normal' className='h-6 border-rose-500/40 px-2 text-sm text-rose-600 dark:text-rose-400'
> >
{p.type} {t('required')}
</Badge> </Badge>
</TableCell> )}
<TableCell className='py-2 align-top'> </div>
<ParamRangeCell param={p} /> ),
</TableCell> },
<TableCell className='text-muted-foreground py-2 align-top'> {
{t(p.descriptionKey)} id: 'type',
</TableCell> header: t('Type'),
</TableRow> className: 'h-9 w-24',
))} cellClassName: tableStyles.topCell,
</TableBody> cell: (p) => (
</Table> <Badge
</div> 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> </section>
) )
} }
@@ -671,34 +676,43 @@ function RateLimitsSection(props: { model: PricingModel }) {
return ( return (
<section> <section>
<SectionTitle icon={Gauge}>{t('Rate limits')}</SectionTitle> <SectionTitle icon={Gauge}>{t('Rate limits')}</SectionTitle>
<div className='border-border/60 overflow-hidden rounded-lg border'> <StaticDataTable
<Table> className={tableStyles.sectionContainer}
<TableHeader> headerRowClassName={tableStyles.mutedHeaderRow}
<TableRow className='bg-muted/30 hover:bg-muted/30'> data={limits}
<TableHead className='h-9'>{t('Group')}</TableHead> getRowKey={(limit) => limit.group}
<TableHead className='h-9 text-right'>RPM</TableHead> getRowClassName={() => 'hover:bg-muted/20'}
<TableHead className='h-9 text-right'>TPM</TableHead> columns={[
<TableHead className='h-9 text-right'>RPD</TableHead> {
</TableRow> id: 'group',
</TableHeader> header: t('Group'),
<TableBody> className: 'h-9',
{limits.map((l) => ( cellClassName: 'py-2 font-mono',
<TableRow key={l.group} className='hover:bg-muted/20'> cell: (limit) => limit.group,
<TableCell className='py-2 font-mono'>{l.group}</TableCell> },
<TableCell className='py-2 text-right font-mono'> {
{formatRateLimit(l.rpm)} id: 'rpm',
</TableCell> header: 'RPM',
<TableCell className='py-2 text-right font-mono'> className: 'h-9 text-right',
{formatRateLimit(l.tpm)} cellClassName: tableStyles.topNumericCell,
</TableCell> cell: (limit) => formatRateLimit(limit.rpm),
<TableCell className='py-2 text-right font-mono'> },
{formatRateLimit(l.rpd)} {
</TableCell> id: 'tpm',
</TableRow> header: 'TPM',
))} className: 'h-9 text-right',
</TableBody> cellClassName: tableStyles.topNumericCell,
</Table> cell: (limit) => formatRateLimit(limit.tpm),
</div> },
{
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'> <p className='text-muted-foreground mt-2 text-[11px] leading-relaxed'>
{t( {t(
'RPM = requests per minute, TPM = tokens per minute, RPD = requests per day. Limits apply per token group.' 'RPM = requests per minute, TPM = tokens per minute, RPD = requests per day. Limits apply per token group.'
@@ -26,13 +26,9 @@ import {
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
Table, StaticDataTable,
TableBody, staticDataTableClassNames as tableStyles,
TableCell, } from '@/components/data-table'
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { import {
buildAppRankings, buildAppRankings,
formatTokenVolume, formatTokenVolume,
@@ -123,9 +119,6 @@ export function ModelDetailsApps(props: { model: PricingModel }) {
const totalMonthlyTokens = apps.reduce((s, a) => s + a.monthly_tokens, 0) const totalMonthlyTokens = apps.reduce((s, a) => s + a.monthly_tokens, 0)
const top = apps[0] const top = apps[0]
const headerCellClass =
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
return ( return (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'> <div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
@@ -165,60 +158,70 @@ export function ModelDetailsApps(props: { model: PricingModel }) {
</div> </div>
</div> </div>
<div className='overflow-x-auto rounded-lg border'> <StaticDataTable
<Table className='text-sm'> className='rounded-lg'
<TableHeader> tableClassName='text-sm'
<TableRow className='hover:bg-transparent'> headerRowClassName={tableStyles.compactHeaderRow}
<TableHead className={cn(headerCellClass, 'w-12')}>#</TableHead> data={apps}
<TableHead className={headerCellClass}>{t('App')}</TableHead> getRowKey={(app) => `${app.rank}-${app.name}`}
<TableHead columns={[
className={cn(headerCellClass, 'hidden md:table-cell')} {
> id: 'rank',
{t('Category')} header: '#',
</TableHead> className: cn(tableStyles.compactHeaderCell, 'w-12'),
<TableHead className={`${headerCellClass} text-right`}> cellClassName: tableStyles.compactCell,
{t('Monthly tokens')} cell: (app) => <RankBadge rank={app.rank} />,
</TableHead> },
<TableHead className={`${headerCellClass} text-right`}> {
{t('30d change')} id: 'app',
</TableHead> header: t('App'),
</TableRow> className: tableStyles.compactHeaderCell,
</TableHeader> cellClassName: tableStyles.compactCell,
<TableBody> cell: (app) => (
{apps.map((app) => ( <div className='flex items-center gap-3'>
<TableRow key={`${app.rank}-${app.name}`}> <span className='bg-muted text-muted-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md font-bold'>
<TableCell className='py-2.5'> {app.initial}
<RankBadge rank={app.rank} /> </span>
</TableCell> <div className='min-w-0'>
<TableCell className='py-2.5'> <div className='text-sm font-medium'>
<div className='flex items-center gap-3'> <AppLink app={app} />
<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>
</div> </div>
</TableCell> <p className='text-muted-foreground line-clamp-1 text-sm'>
<TableCell className='text-muted-foreground hidden py-2.5 md:table-cell'> {app.description}
{app.category} </p>
</TableCell> </div>
<TableCell className='py-2.5 text-right font-mono tabular-nums'> </div>
{formatTokenVolume(app.monthly_tokens)} ),
</TableCell> },
<TableCell className='py-2.5 text-right'> {
<GrowthChip value={app.growth_pct} /> id: 'category',
</TableCell> header: t('Category'),
</TableRow> className: cn(
))} tableStyles.compactHeaderCell,
</TableBody> 'hidden md:table-cell'
</Table> ),
</div> 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'> <p className='text-muted-foreground/60 text-[11px] leading-relaxed'>
{t( {t(
@@ -30,6 +30,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { StaticDataTable } from '@/components/data-table'
import type { Modality } from '../types' import type { Modality } from '../types'
type IconComponent = React.ComponentType<{ className?: string }> type IconComponent = React.ComponentType<{ className?: string }>
@@ -95,79 +96,65 @@ export function ModalitiesMatrix(props: {
const inputSet = new Set(props.input) const inputSet = new Set(props.input)
const outputSet = new Set(props.output) const outputSet = new Set(props.output)
const renderRow = (label: string, set: Set<Modality>) => ( return (
<tr> <StaticDataTable
<th className='rounded-lg'
scope='row' tableClassName='text-sm'
className='text-muted-foreground bg-muted/30 px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase' headerRowClassName='bg-muted/40'
> data={[
{label} { label: t('Input'), set: inputSet },
</th> { label: t('Output'), set: outputSet },
{ALL_MODALITIES.map((modality) => { ]}
const enabled = set.has(modality) getRowKey={(row) => row.label}
const Icon = MODALITY_META[modality].icon columns={[
return ( {
<td id: 'modality',
key={modality} header: t('Modality'),
className={cn( 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', 'border-l px-3 py-2 text-center',
enabled row.set.has(modality)
? 'bg-emerald-50/40 dark:bg-emerald-500/10' ? 'bg-emerald-50/40 dark:bg-emerald-500/10'
: 'bg-background' : 'bg-background'
)} ),
> cell: (row: { label: string; set: Set<Modality> }) => {
<span const enabled = row.set.has(modality)
className={cn( const Icon = MODALITY_META[modality].icon
'inline-flex items-center justify-center', return (
enabled <span
? 'text-emerald-700 dark:text-emerald-300' className={cn(
: 'text-muted-foreground/40' 'inline-flex items-center justify-center',
)} enabled
aria-label={ ? 'text-emerald-700 dark:text-emerald-300'
enabled : 'text-muted-foreground/40'
? t('{{modality}} supported', { )}
modality: t(MODALITY_META[modality].labelKey), aria-label={
}) enabled
: t('{{modality}} not supported', { ? t('{{modality}} supported', {
modality: t(MODALITY_META[modality].labelKey), modality: t(MODALITY_META[modality].labelKey),
}) })
} : t('{{modality}} not supported', {
> modality: t(MODALITY_META[modality].labelKey),
<Icon className='size-4' /> })
</span> }
</td>
)
})}
</tr>
)
return (
<div className='overflow-x-auto rounded-lg border'>
<table className='w-full text-sm'>
<thead>
<tr className='bg-muted/40'>
<th
scope='col'
className='text-muted-foreground px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
>
{t('Modality')}
</th>
{ALL_MODALITIES.map((modality) => (
<th
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)} <Icon className='size-4' />
</th> </span>
))} )
</tr> },
</thead> })),
<tbody> ]}
{renderRow(t('Input'), inputSet)} />
{renderRow(t('Output'), outputSet)}
</tbody>
</table>
</div>
) )
} }
@@ -22,13 +22,9 @@ import { AlertTriangle, HeartPulse, Timer } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
Table, StaticDataTable,
TableBody, staticDataTableClassNames as tableStyles,
TableCell, } from '@/components/data-table'
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { GroupBadge } from '@/components/group-badge' import { GroupBadge } from '@/components/group-badge'
import { getPerfMetrics } from '@/features/performance-metrics/api' import { getPerfMetrics } from '@/features/performance-metrics/api'
import { import {
@@ -218,9 +214,6 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
intent = 'default' intent = 'default'
} }
const headerCellClass =
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
return ( return (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'> <div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
@@ -256,53 +249,55 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
title={t('Per-group performance')} title={t('Per-group performance')}
description={t('Average latency, TTFT, TPS, and success rate')} description={t('Average latency, TTFT, TPS, and success rate')}
/> />
<div className='overflow-x-auto rounded-lg border'> <StaticDataTable
<Table className='text-sm'> className='rounded-lg'
<TableHeader> tableClassName='text-sm'
<TableRow className='hover:bg-transparent'> headerRowClassName={tableStyles.compactHeaderRow}
<TableHead className={headerCellClass}>{t('Group')}</TableHead> data={performances}
<TableHead className={`${headerCellClass} text-right`}> getRowKey={(perf) => perf.group}
TPS columns={[
</TableHead> {
<TableHead className={`${headerCellClass} text-right`}> id: 'group',
{t('Average TTFT')} header: t('Group'),
</TableHead> className: tableStyles.compactHeaderCell,
<TableHead className={`${headerCellClass} text-right`}> cellClassName: tableStyles.compactCell,
{t('Average latency')} cell: (perf) => <GroupBadge group={perf.group} size='sm' />,
</TableHead> },
<TableHead {
className={`${headerCellClass} min-w-[180px] text-left`} id: 'tps',
> header: 'TPS',
{t('Success rate')} className: tableStyles.compactHeaderCellRight,
</TableHead> cellClassName: tableStyles.compactNumericCell,
</TableRow> cell: (perf) => formatThroughput(perf.avg_tps),
</TableHeader> },
<TableBody> {
{performances.map((perf) => ( id: 'ttft',
<TableRow key={perf.group}> header: t('Average TTFT'),
<TableCell className='py-2.5'> className: tableStyles.compactHeaderCellRight,
<GroupBadge group={perf.group} size='sm' /> cellClassName: tableStyles.compactNumericCell,
</TableCell> cell: (perf) => formatLatency(perf.avg_ttft_ms),
<TableCell className='py-2.5 text-right font-mono'> },
{formatThroughput(perf.avg_tps)} {
</TableCell> id: 'latency',
<TableCell className='py-2.5 text-right font-mono'> header: t('Average latency'),
{formatLatency(perf.avg_ttft_ms)} className: tableStyles.compactHeaderCellRight,
</TableCell> cellClassName: tableStyles.compactMutedNumericCell,
<TableCell className='text-muted-foreground py-2.5 text-right font-mono'> cell: (perf) => formatLatency(perf.avg_latency_ms),
{formatLatency(perf.avg_latency_ms)} },
</TableCell> {
<TableCell className='py-2.5'> id: 'success',
<UptimeSparkline header: t('Success rate'),
size='sm' className: cn(tableStyles.compactHeaderCell, 'min-w-[180px]'),
series={uptimeByGroup[perf.group] ?? []} cellClassName: tableStyles.compactCell,
/> cell: (perf) => (
</TableCell> <UptimeSparkline
</TableRow> size='sm'
))} series={uptimeByGroup[perf.group] ?? []}
</TableBody> />
</Table> ),
</div> },
]}
/>
</section> </section>
<section> <section>
+167 -177
View File
@@ -32,16 +32,9 @@ import {
SheetTitle, SheetTitle,
} from '@/components/ui/sheet' } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { CopyButton } from '@/components/copy-button' import { CopyButton } from '@/components/copy-button'
import { StaticDataTable } from '@/components/data-table'
import { sideDrawerContentClassName } from '@/components/drawer-layout' import { sideDrawerContentClassName } from '@/components/drawer-layout'
import { GroupBadge } from '@/components/group-badge' import { GroupBadge } from '@/components/group-badge'
import { PublicLayout } from '@/components/layout' import { PublicLayout } from '@/components/layout'
@@ -269,9 +262,7 @@ function ModelHeader(props: { model: PricingModel }) {
const { t } = useTranslation() const { t } = useTranslation()
const model = props.model const model = props.model
const modelIconKey = model.icon || model.vendor_icon const modelIconKey = model.icon || model.vendor_icon
const modelIcon = modelIconKey const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 20) : null
? getLobeIcon(modelIconKey, 20)
: null
const description = model.description || model.vendor_description || null const description = model.description || model.vendor_description || null
const tags = parseTags(model.tags) const tags = parseTags(model.tags)
const isSpecialExpression = const isSpecialExpression =
@@ -586,6 +577,40 @@ function AutoGroupChain(props: { model: PricingModel; autoGroups: string[] }) {
) )
} }
type DynamicPriceOptions = Parameters<typeof getDynamicPriceEntries>[1]
type DynamicPricingTier = ReturnType<typeof getDynamicPricingTiers>[number]
type DynamicFormattedPricesByTier = Map<DynamicPricingTier, Map<string, string>>
function getDynamicPriceFields(
tiers: DynamicPricingTier[],
options: DynamicPriceOptions
) {
return Array.from(
new Map(
tiers
.flatMap((tier) => getDynamicPriceEntries(tier, options))
.map((entry) => [entry.field, entry])
).values()
)
}
function getDynamicFormattedPricesByTier(
tiers: DynamicPricingTier[],
options: DynamicPriceOptions
): DynamicFormattedPricesByTier {
return new Map(
tiers.map((tier) => [
tier,
new Map(
getDynamicPriceEntries(tier, options).map((entry) => [
entry.field,
entry.formatted,
])
),
])
)
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Group pricing table // Group pricing table
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -676,20 +701,27 @@ function GroupPricingSection(props: {
) )
} }
const priceFields = Array.from( const priceFields = getDynamicPriceFields(dynamicTiers, {
new Map( tokenUnit: props.tokenUnit,
dynamicTiers showRechargePrice,
.flatMap((tier) => priceRate: props.priceRate,
getDynamicPriceEntries(tier, { usdExchangeRate: props.usdExchangeRate,
tokenUnit: props.tokenUnit, groupRatioMultiplier: 1,
showRechargePrice, })
priceRate: props.priceRate, const formattedPricesByGroup = new Map(
usdExchangeRate: props.usdExchangeRate, availableGroups.map((group) => {
groupRatioMultiplier: 1, const ratio = props.groupRatio[group] || 1
}) return [
) group,
.map((entry) => [entry.field, entry]) getDynamicFormattedPricesByTier(dynamicTiers, {
).values() tokenUnit: props.tokenUnit,
showRechargePrice,
priceRate: props.priceRate,
usdExchangeRate: props.usdExchangeRate,
groupRatioMultiplier: ratio,
}),
] as const
})
) )
return ( return (
@@ -699,6 +731,10 @@ function GroupPricingSection(props: {
<div className='space-y-3'> <div className='space-y-3'>
{availableGroups.map((group) => { {availableGroups.map((group) => {
const ratio = props.groupRatio[group] || 1 const ratio = props.groupRatio[group] || 1
const formattedPricesByTier =
formattedPricesByGroup.get(group) ??
new Map<DynamicPricingTier, Map<string, string>>()
return ( return (
<div key={group} className='overflow-hidden rounded-lg border'> <div key={group} className='overflow-hidden rounded-lg border'>
<div className='bg-muted/20 flex items-center justify-between gap-3 border-b px-3 py-2'> <div className='bg-muted/20 flex items-center justify-between gap-3 border-b px-3 py-2'>
@@ -707,56 +743,34 @@ function GroupPricingSection(props: {
{ratio}x {ratio}x
</span> </span>
</div> </div>
<div className='overflow-x-auto'> <StaticDataTable
<Table className='text-sm'> className='rounded-none border-0'
<TableHeader> tableClassName='text-sm'
<TableRow className='hover:bg-transparent'> headerRowClassName='hover:bg-transparent'
<TableHead className={thClass}>{t('Tier')}</TableHead> data={dynamicTiers}
{priceFields.map((entry) => ( getRowKey={(tier, tierIndex) =>
<TableHead `${group}-${tier.label || tierIndex}`
key={entry.field} }
className={`${thClass} text-right`} columns={[
> {
{t(entry.shortLabel)} id: 'tier',
</TableHead> header: t('Tier'),
))} className: thClass,
</TableRow> cellClassName: 'text-muted-foreground py-2.5',
</TableHeader> cell: (tier) => tier.label || t('Default'),
<TableBody> },
{dynamicTiers.map((tier, tierIndex) => { ...priceFields.map((fieldEntry) => ({
const entries = getDynamicPriceEntries(tier, { id: fieldEntry.field,
tokenUnit: props.tokenUnit, header: t(fieldEntry.shortLabel),
showRechargePrice, className: `${thClass} text-right`,
priceRate: props.priceRate, cellClassName: 'py-2.5 text-right font-mono',
usdExchangeRate: props.usdExchangeRate, cell: (tier: (typeof dynamicTiers)[number]) =>
groupRatioMultiplier: ratio, formattedPricesByTier
}) .get(tier)
const entryMap = new Map( ?.get(fieldEntry.field) ?? '-',
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>
</Table>
</div>
</div> </div>
) )
})} })}
@@ -768,112 +782,88 @@ function GroupPricingSection(props: {
) )
} }
const renderGroupPrice = (group: string, type: PriceType) =>
formatGroupPrice(
props.model,
group,
type,
props.tokenUnit,
showRechargePrice,
props.priceRate,
props.usdExchangeRate,
props.groupRatio
)
const renderFixedGroupPrice = (group: string) =>
formatFixedPrice(
props.model,
group,
showRechargePrice,
props.priceRate,
props.usdExchangeRate,
props.groupRatio
)
return ( return (
<section> <section>
<SectionTitle>{t('Pricing by Group')}</SectionTitle> <SectionTitle>{t('Pricing by Group')}</SectionTitle>
<AutoGroupChain model={props.model} autoGroups={props.autoGroups} /> <AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
<div className='-mx-4 overflow-x-auto sm:mx-0'> <StaticDataTable
<Table className='text-sm'> className='-mx-4 rounded-none border-0 sm:mx-0'
<TableHeader> tableClassName='text-sm'
<TableRow className='hover:bg-transparent'> headerRowClassName='hover:bg-transparent'
<TableHead className={thClass}>{t('Group')}</TableHead> data={availableGroups}
<TableHead className={thClass}>{t('Ratio')}</TableHead> getRowKey={(group) => group}
{isTokenBased ? ( columns={[
<> {
<TableHead className={`${thClass} text-right`}> id: 'group',
{t('Input')} header: t('Group'),
</TableHead> className: thClass,
<TableHead className={`${thClass} text-right`}> cellClassName: 'py-2.5',
{t('Output')} cell: (group) => <GroupBadge group={group} size='sm' />,
</TableHead> },
{extraPriceTypes.map((ep) => ( {
<TableHead id: 'ratio',
key={ep.type} header: t('Ratio'),
className={`${thClass} text-right`} className: thClass,
> cellClassName: 'text-muted-foreground py-2.5 font-mono',
{ep.label} cell: (group) => `${props.groupRatio[group] || 1}x`,
</TableHead> },
))} ...(isTokenBased
</> ? [
) : ( {
<TableHead className={`${thClass} text-right`}> id: 'input',
{t('Price')} header: t('Input'),
</TableHead> className: `${thClass} text-right`,
)} cellClassName: 'py-2.5 text-right font-mono',
</TableRow> cell: (group: string) => renderGroupPrice(group, 'input'),
</TableHeader> },
<TableBody> {
{availableGroups.map((group) => { id: 'output',
const ratio = props.groupRatio[group] || 1 header: t('Output'),
return ( className: `${thClass} text-right`,
<TableRow key={group}> cellClassName: 'py-2.5 text-right font-mono',
<TableCell className='py-2.5'> cell: (group: string) => renderGroupPrice(group, 'output'),
<GroupBadge group={group} size='sm' /> },
</TableCell> ...extraPriceTypes.map((ep) => ({
<TableCell className='text-muted-foreground py-2.5 font-mono'> id: ep.type,
{ratio}x header: ep.label,
</TableCell> className: `${thClass} text-right`,
{isTokenBased ? ( cellClassName: 'py-2.5 text-right font-mono',
<> cell: (group: string) => renderGroupPrice(group, ep.type),
<TableCell className='py-2.5 text-right font-mono'> })),
{formatGroupPrice( ]
props.model, : [
group, {
'input', id: 'price',
props.tokenUnit, header: t('Price'),
showRechargePrice, className: `${thClass} text-right`,
props.priceRate, cellClassName: 'py-2.5 text-right font-mono',
props.usdExchangeRate, cell: renderFixedGroupPrice,
props.groupRatio },
)} ]),
</TableCell> ]}
<TableCell className='py-2.5 text-right font-mono'> />
{formatGroupPrice( <div className='-mx-4 sm:mx-0'>
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(
props.model,
group,
showRechargePrice,
props.priceRate,
props.usdExchangeRate,
props.groupRatio
)}
</TableCell>
)}
</TableRow>
)
})}
</TableBody>
</Table>
{isTokenBased && ( {isTokenBased && (
<p className='text-muted-foreground/40 mt-1.5 px-4 text-[10px] sm:px-0'> <p className='text-muted-foreground/40 mt-1.5 px-4 text-[10px] sm:px-0'>
{t('Prices shown per')} {tokenUnitLabel} tokens {t('Prices shown per')} {tokenUnitLabel} tokens
@@ -25,7 +25,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { DataTableColumnHeader } from '@/components/data-table/column-header' import { DataTableColumnHeader } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge' import { GroupBadge } from '@/components/group-badge'
import { StatusBadge, StatusBadgeList } from '@/components/status-badge' import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants' import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
@@ -107,9 +107,7 @@ export function usePricingColumns(
cell: ({ row }) => { cell: ({ row }) => {
const model = row.original const model = row.original
const modelIconKey = model.icon || model.vendor_icon const modelIconKey = model.icon || model.vendor_icon
const modelIcon = modelIconKey const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 14) : null
? getLobeIcon(modelIconKey, 14)
: null
return ( return (
<div className='flex min-w-[200px] items-center gap-2'> <div className='flex min-w-[200px] items-center gap-2'>
+30 -72
View File
@@ -17,24 +17,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useState, useCallback } from 'react' import { useState, useCallback } from 'react'
import { import { type Row, type PaginationState } from '@tanstack/react-table'
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type PaginationState,
} from '@tanstack/react-table'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
Table, DataTablePagination,
TableBody, DataTableRow,
TableCell, DataTableView,
TableHead, useDataTable,
TableHeader, } from '@/components/data-table'
TableRow,
} from '@/components/ui/table'
import { TableSkeleton, TableEmpty } from '@/components/data-table'
import { DataTablePagination } from '@/components/data-table/pagination'
import { DEFAULT_PRICING_PAGE_SIZE, DEFAULT_TOKEN_UNIT } from '../constants' import { DEFAULT_PRICING_PAGE_SIZE, DEFAULT_TOKEN_UNIT } from '../constants'
import type { PricingModel, TokenUnit } from '../types' import type { PricingModel, TokenUnit } from '../types'
import { usePricingColumns } from './pricing-columns' import { usePricingColumns } from './pricing-columns'
@@ -73,15 +63,16 @@ export function PricingTable(props: PricingTableProps) {
showRechargePrice, showRechargePrice,
}) })
const table = useReactTable({ const { table } = useDataTable({
data: models, data: models,
columns, columns,
pageCount: Math.ceil(models.length / pagination.pageSize), pageCount: Math.ceil(models.length / pagination.pageSize),
state: { pagination }, pagination,
onPaginationChange: setPagination, onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: false, manualPagination: false,
withFilteredRowModel: false,
withSortedRowModel: false,
withFacetedRowModel: false,
}) })
const handleRowClick = useCallback( const handleRowClick = useCallback(
@@ -93,58 +84,25 @@ export function PricingTable(props: PricingTableProps) {
return ( return (
<div className='space-y-4'> <div className='space-y-4'>
<div className='overflow-hidden rounded-lg border'> <DataTableView
<Table> table={table}
<TableHeader> isLoading={isLoading}
{table.getHeaderGroups().map((headerGroup) => ( emptyTitle={t('No Models Found')}
<TableRow key={headerGroup.id}> emptyDescription={t('No models match your current filters.')}
{headerGroup.headers.map((header) => ( skeletonKeyPrefix='pricing-skeleton'
<TableHead applyHeaderSize
key={header.id} getColumnClassName={(_columnId, kind) =>
style={{ width: header.getSize() }} kind === 'header' ? 'text-muted-foreground font-medium' : undefined
className='text-muted-foreground font-medium' }
> renderRow={(row: Row<PricingModel>) => (
{header.isPlaceholder <DataTableRow
? null key={row.id}
: flexRender( row={row}
header.column.columnDef.header, className='hover:bg-muted/30 cursor-pointer transition-colors'
header.getContext() onClick={() => handleRowClick(row.original)}
)} />
</TableHead> )}
))} />
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton table={table} keyPrefix='pricing-skeleton' />
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={columns.length}
title={t('No Models Found')}
description={t('No models match your current filters.')}
/>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
onClick={() => handleRowClick(row.original)}
className='hover:bg-muted/30 cursor-pointer transition-colors'
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!isLoading && models.length > 0 && <DataTablePagination table={table} />} {!isLoading && models.length > 0 && <DataTablePagination table={table} />}
</div> </div>
@@ -59,6 +59,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
), ),
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
size: 40,
}, },
{ {
accessorKey: 'id', accessorKey: 'id',
@@ -71,6 +72,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
<TableId value={row.getValue('id') as number} className='w-[60px]' /> <TableId value={row.getValue('id') as number} className='w-[60px]' />
) )
}, },
size: 80,
}, },
{ {
accessorKey: 'name', accessorKey: 'name',
@@ -85,6 +87,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
</div> </div>
) )
}, },
size: 180,
}, },
{ {
accessorKey: 'status', accessorKey: 'status',
@@ -135,6 +138,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
// Check regular status // Check regular status
return value.includes(String(statusValue)) return value.includes(String(statusValue))
}, },
size: 120,
}, },
{ {
id: 'code', id: 'code',
@@ -159,6 +163,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
) )
}, },
enableSorting: false, enableSorting: false,
size: 320,
}, },
{ {
accessorKey: 'quota', accessorKey: 'quota',
@@ -176,6 +181,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
/> />
) )
}, },
size: 120,
}, },
{ {
accessorKey: 'created_time', accessorKey: 'created_time',
@@ -185,11 +191,12 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
), ),
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div className='min-w-[140px] font-mono text-sm'> <div className='min-w-[160px] font-mono text-sm'>
{formatTimestampToDate(row.getValue('created_time'))} {formatTimestampToDate(row.getValue('created_time'))}
</div> </div>
) )
}, },
size: 180,
}, },
{ {
accessorKey: 'expired_time', accessorKey: 'expired_time',
@@ -211,12 +218,13 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
const isExpired = isTimestampExpired(expiredTime) const isExpired = isTimestampExpired(expiredTime)
return ( return (
<div <div
className={`min-w-[140px] font-mono text-sm ${isExpired ? 'text-destructive' : ''}`} className={`min-w-[160px] font-mono text-sm ${isExpired ? 'text-destructive' : ''}`}
> >
{formatTimestampToDate(expiredTime)} {formatTimestampToDate(expiredTime)}
</div> </div>
) )
}, },
size: 180,
}, },
{ {
accessorKey: 'used_user_id', accessorKey: 'used_user_id',
@@ -260,10 +268,12 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
</Tooltip> </Tooltip>
) )
}, },
size: 140,
}, },
{ {
id: 'actions', id: 'actions',
cell: ({ row }) => <DataTableRowActions row={row} />, cell: ({ row }) => <DataTableRowActions row={row} />,
size: 88,
}, },
] ]
} }
@@ -16,20 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useEffect, useMemo, useState } from 'react' import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router' import { getRouteApi } from '@tanstack/react-router'
import {
type SortingState,
type VisibilityState,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks' import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useTableUrlState } from '@/hooks/use-table-url-state' import { useTableUrlState } from '@/hooks/use-table-url-state'
@@ -37,6 +26,7 @@ import {
DISABLED_ROW_DESKTOP, DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE, DISABLED_ROW_MOBILE,
DataTablePage, DataTablePage,
useDataTable,
} from '@/components/data-table' } from '@/components/data-table'
import { getRedemptions, searchRedemptions } from '../api' import { getRedemptions, searchRedemptions } from '../api'
import { REDEMPTION_STATUS, getRedemptionStatusOptions } from '../constants' import { REDEMPTION_STATUS, getRedemptionStatusOptions } from '../constants'
@@ -60,9 +50,6 @@ export function RedemptionsTable() {
const columns = useRedemptionsColumns() const columns = useRedemptionsColumns()
const { refreshTrigger } = useRedemptions() const { refreshTrigger } = useRedemptions()
const isMobile = useMediaQuery('(max-width: 640px)') const isMobile = useMediaQuery('(max-width: 640px)')
const [rowSelection, setRowSelection] = useState({})
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const { const {
globalFilter, globalFilter,
@@ -110,21 +97,13 @@ export function RedemptionsTable() {
const redemptions = data?.items || [] const redemptions = data?.items || []
const table = useReactTable({ const { table } = useDataTable({
data: redemptions, data: redemptions,
columns, columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
globalFilter,
pagination,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, columnFilters,
onSortingChange: setSorting, globalFilter,
onColumnVisibilityChange: setColumnVisibility, pagination,
globalFilterFn: (row, _columnId, filterValue) => { globalFilterFn: (row, _columnId, filterValue) => {
const name = String(row.getValue('name')).toLowerCase() const name = String(row.getValue('name')).toLowerCase()
const id = String(row.getValue('id')) const id = String(row.getValue('id'))
@@ -132,24 +111,14 @@ export function RedemptionsTable() {
return name.includes(searchValue) || id.includes(searchValue) return name.includes(searchValue) || id.includes(searchValue)
}, },
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
onPaginationChange, onPaginationChange,
onGlobalFilterChange, onGlobalFilterChange,
onColumnFiltersChange, onColumnFiltersChange,
manualPagination: !globalFilter, manualPagination: !globalFilter,
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize), totalCount: data?.total || 0,
ensurePageInRange,
}) })
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [pageCount, ensurePageInRange])
const redemptionStatusOptions = useMemo( const redemptionStatusOptions = useMemo(
() => getRedemptionStatusOptions(t), () => getRedemptionStatusOptions(t),
[t] [t]
@@ -166,6 +135,7 @@ export function RedemptionsTable() {
'No redemption codes available. Create your first redemption code to get started.' 'No redemption codes available. Create your first redemption code to get started.'
)} )}
skeletonKeyPrefix='redemptions-skeleton' skeletonKeyPrefix='redemptions-skeleton'
applyHeaderSize
toolbarProps={{ toolbarProps={{
searchPlaceholder: t('Filter by name or ID...'), searchPlaceholder: t('Filter by name or ID...'),
filters: [ filters: [
+1 -1
View File
@@ -27,7 +27,7 @@ export function Redemptions() {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<RedemptionsProvider> <RedemptionsProvider>
<SectionPageLayout> <SectionPageLayout fixedContent>
<SectionPageLayout.Title> <SectionPageLayout.Title>
{t('Redemption Codes')} {t('Redemption Codes')}
</SectionPageLayout.Title> </SectionPageLayout.Title>
@@ -36,15 +36,8 @@ import {
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} from '@/components/ui/sheet' } from '@/components/ui/sheet'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { StaticDataTable } from '@/components/data-table'
import { import {
sideDrawerContentClassName, sideDrawerContentClassName,
sideDrawerFormClassName, sideDrawerFormClassName,
@@ -245,112 +238,117 @@ export function UserSubscriptionsDialog(props: Props) {
</Button> </Button>
</div> </div>
<div className='rounded-md border'> <StaticDataTable
<Table> data={loading ? [] : subs}
<TableHeader> getRowKey={(record) => record.subscription.id}
<TableRow> emptyClassName={loading ? 'py-8' : 'text-muted-foreground py-8'}
<TableHead>ID</TableHead> emptyContent={
<TableHead>{t('Plan')}</TableHead> loading ? t('Loading...') : t('No subscription records')
<TableHead>{t('Status')}</TableHead> }
<TableHead>{t('Validity')}</TableHead> columns={[
<TableHead>{t('Total Quota')}</TableHead> {
<TableHead className='text-right'>{t('Actions')}</TableHead> id: 'id',
</TableRow> header: t('ID'),
</TableHeader> cell: (record) => <TableId value={record.subscription.id} />,
<TableBody> },
{loading ? ( {
<TableRow> id: 'plan',
<TableCell colSpan={6} className='py-8 text-center'> header: t('Plan'),
{t('Loading...')} cell: (record) => {
</TableCell> const sub = record.subscription
</TableRow>
) : subs.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className='text-muted-foreground py-8 text-center'
>
{t('No subscription records')}
</TableCell>
</TableRow>
) : (
subs.map((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 ( return (
<TableRow key={sub.id}> <div>
<TableCell> <div className='font-medium'>
<TableId value={sub.id} /> {planTitleMap.get(sub.plan_id) || `#${sub.plan_id}`}
</TableCell> </div>
<TableCell> <div className='text-muted-foreground text-sm'>
<div> {t('Source')}: {sub.source || '-'}
<div className='font-medium'> </div>
{planTitleMap.get(sub.plan_id) || </div>
`#${sub.plan_id}`} )
</div> },
<div className='text-muted-foreground text-sm'> },
{t('Source')}: {sub.source || '-'} {
</div> id: 'status',
</div> header: t('Status'),
</TableCell> cell: (record) => (
<TableCell> <SubscriptionStatusBadge sub={record.subscription} t={t} />
<SubscriptionStatusBadge sub={sub} t={t} /> ),
</TableCell> },
<TableCell> {
<div className='text-sm'> id: 'validity',
<div> header: t('Validity'),
{t('Start')}: {formatTimestamp(sub.start_time)} cell: (record) => {
</div> const sub = record.subscription
<div>
{t('End')}: {formatTimestamp(sub.end_time)} return (
</div> <div className='text-sm'>
</div> <div>
</TableCell> {t('Start')}: {formatTimestamp(sub.start_time)}
<TableCell> </div>
{total > 0 ? `${used}/${total}` : t('Unlimited')} <div>
</TableCell> {t('End')}: {formatTimestamp(sub.end_time)}
<TableCell className='text-right'> </div>
<div className='flex justify-end gap-1'> </div>
<Button )
size='sm' },
variant='outline' },
disabled={!isActive} {
onClick={() => id: 'quota',
setConfirmAction({ header: t('Total Quota'),
type: 'invalidate', cell: (record) => {
subId: sub.id, 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')
{t('Invalidate')} },
</Button> },
<Button {
size='sm' id: 'actions',
variant='destructive' header: t('Actions'),
onClick={() => className: 'text-right',
setConfirmAction({ cellClassName: 'text-right',
type: 'delete', cell: (record) => {
subId: sub.id, const sub = record.subscription
}) const now = Date.now() / 1000
} const isExpired =
> (sub.end_time || 0) > 0 && sub.end_time < now
{t('Delete')} const isActive = sub.status === 'active' && !isExpired
</Button>
</div> return (
</TableCell> <div className='flex justify-end gap-1'>
</TableRow> <Button
) size='sm'
}) variant='outline'
)} disabled={!isActive}
</TableBody> onClick={() =>
</Table> setConfirmAction({
</div> type: 'invalidate',
subId: sub.id,
})
}
>
{t('Invalidate')}
</Button>
<Button
size='sm'
variant='destructive'
onClick={() =>
setConfirmAction({
type: 'delete',
subId: sub.id,
})
}
>
{t('Delete')}
</Button>
</div>
)
},
},
]}
/>
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@@ -36,9 +36,9 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
{ {
accessorFn: (row) => row.plan.id, accessorFn: (row) => row.plan.id,
id: 'id', id: 'id',
meta: { label: 'ID', mobileHidden: true }, meta: { label: t('ID'), mobileHidden: true },
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader column={column} title='ID' /> <DataTableColumnHeader column={column} title={t('ID')} />
), ),
cell: ({ row }) => <TableId value={row.original.plan.id} />, cell: ({ row }) => <TableId value={row.original.plan.id} />,
size: 60, size: 60,
@@ -103,7 +103,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
{formatResetPeriod(row.original.plan, t)} {formatResetPeriod(row.original.plan, t)}
</span> </span>
), ),
size: 80, size: 100,
}, },
{ {
accessorFn: (row) => row.plan.sort_order, accessorFn: (row) => row.plan.sort_order,
@@ -117,7 +117,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
{row.original.plan.sort_order} {row.original.plan.sort_order}
</span> </span>
), ),
size: 80, size: 100,
}, },
{ {
accessorFn: (row) => row.plan.enabled, accessorFn: (row) => row.plan.enabled,
@@ -188,7 +188,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
</span> </span>
) )
}, },
size: 100, size: 150,
}, },
{ {
id: 'upgrade_group', id: 'upgrade_group',
@@ -205,7 +205,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
} }
return <GroupBadge group={group} /> return <GroupBadge group={group} />
}, },
size: 100, size: 120,
}, },
{ {
id: 'actions', id: 'actions',
@@ -16,18 +16,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useMemo, useState } from 'react' import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import {
type SortingState,
type VisibilityState,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DataTablePage } from '@/components/data-table' import { DataTablePage, useDataTable } from '@/components/data-table'
import { getAdminPlans } from '../api' import { getAdminPlans } from '../api'
import { useSubscriptionsColumns } from './subscriptions-columns' import { useSubscriptionsColumns } from './subscriptions-columns'
import { useSubscriptions } from './subscriptions-provider' import { useSubscriptions } from './subscriptions-provider'
@@ -36,8 +28,6 @@ export function SubscriptionsTable() {
const { t } = useTranslation() const { t } = useTranslation()
const columns = useSubscriptionsColumns() const columns = useSubscriptionsColumns()
const { refreshTrigger } = useSubscriptions() const { refreshTrigger } = useSubscriptions()
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin-subscription-plans', refreshTrigger], queryKey: ['admin-subscription-plans', refreshTrigger],
@@ -50,15 +40,11 @@ export function SubscriptionsTable() {
const plans = useMemo(() => data || [], [data]) const plans = useMemo(() => data || [], [data])
const table = useReactTable({ const { table } = useDataTable({
data: plans, data: plans,
columns, columns,
state: { sorting, columnVisibility }, withFilteredRowModel: false,
onSortingChange: setSorting, withFacetedRowModel: false,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
}) })
return ( return (
@@ -71,6 +57,7 @@ export function SubscriptionsTable() {
'Click "Create Plan" to create your first subscription plan' 'Click "Create Plan" to create your first subscription plan'
)} )}
skeletonKeyPrefix='subscriptions-skeleton' skeletonKeyPrefix='subscriptions-skeleton'
applyHeaderSize
/> />
) )
} }
+15 -11
View File
@@ -34,7 +34,7 @@ function SubscriptionsContent() {
return ( return (
<> <>
<SectionPageLayout> <SectionPageLayout fixedContent>
<SectionPageLayout.Title> <SectionPageLayout.Title>
{t('Subscription Management')} {t('Subscription Management')}
</SectionPageLayout.Title> </SectionPageLayout.Title>
@@ -52,16 +52,20 @@ function SubscriptionsContent() {
</div> </div>
</SectionPageLayout.Actions> </SectionPageLayout.Actions>
<SectionPageLayout.Content> <SectionPageLayout.Content>
{!complianceConfirmed ? ( <div className='flex h-full min-h-0 flex-col gap-4'>
<Alert variant='destructive' className='mb-4'> {!complianceConfirmed ? (
<AlertDescription> <Alert variant='destructive' className='shrink-0'>
{t( <AlertDescription>
'Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.' {t(
)} 'Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.'
</AlertDescription> )}
</Alert> </AlertDescription>
) : null} </Alert>
<SubscriptionsTable /> ) : null}
<div className='min-h-0 flex-1'>
<SubscriptionsTable />
</div>
</div>
</SectionPageLayout.Content> </SectionPageLayout.Content>
</SectionPageLayout> </SectionPageLayout>
@@ -20,15 +20,8 @@ import { useState } from 'react'
import { Pencil, Trash2, Plus } from 'lucide-react' import { Pencil, Trash2, Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { StaticDataTable } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { useDeleteProvider } from '../hooks/use-custom-oauth-mutations' import { useDeleteProvider } from '../hooks/use-custom-oauth-mutations'
import type { CustomOAuthProvider } from '../types' import type { CustomOAuthProvider } from '../types'
@@ -64,73 +57,82 @@ export function ProviderTable(props: ProviderTableProps) {
</Button> </Button>
</div> </div>
{props.providers.length === 0 ? ( <StaticDataTable
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'> data={props.providers}
{t('No custom OAuth providers configured yet.')} getRowKey={(provider) => provider.id}
</div> emptyClassName='text-sm'
) : ( emptyContent={t('No custom OAuth providers configured yet.')}
<Table> columns={[
<TableHeader> {
<TableRow> id: 'icon',
<TableHead>{t('Icon')}</TableHead> header: t('Icon'),
<TableHead>{t('Name')}</TableHead> cell: (provider) =>
<TableHead>{t('Slug')}</TableHead> provider.icon ? (
<TableHead>{t('Status')}</TableHead> <span className='text-lg'>{provider.icon}</span>
<TableHead>{t('Client ID')}</TableHead> ) : (
<TableHead className='text-right'>{t('Actions')}</TableHead> <span className='text-muted-foreground text-sm'>--</span>
</TableRow> ),
</TableHeader> },
<TableBody> {
{props.providers.map((provider) => ( id: 'name',
<TableRow key={provider.id}> header: t('Name'),
<TableCell> cellClassName: 'font-medium',
{provider.icon ? ( cell: (provider) => provider.name,
<span className='text-lg'>{provider.icon}</span> },
) : ( {
<span className='text-muted-foreground text-sm'>--</span> id: 'slug',
)} header: t('Slug'),
</TableCell> cell: (provider) => (
<TableCell className='font-medium'>{provider.name}</TableCell> <StatusBadge
<TableCell> label={provider.slug}
<StatusBadge variant='neutral'
label={provider.slug} copyable={false}
variant='neutral' />
copyable={false} ),
/> },
</TableCell> {
<TableCell> id: 'status',
<StatusBadge header: t('Status'),
label={provider.enabled ? t('Enabled') : t('Disabled')} cell: (provider) => (
variant={provider.enabled ? 'success' : 'neutral'} <StatusBadge
copyable={false} label={provider.enabled ? t('Enabled') : t('Disabled')}
/> variant={provider.enabled ? 'success' : 'neutral'}
</TableCell> copyable={false}
<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'> id: 'client-id',
<Button header: t('Client ID'),
variant='ghost' cellClassName: 'text-muted-foreground max-w-[120px] truncate font-mono',
size='sm' cell: (provider) => provider.client_id,
onClick={() => props.onEdit(provider)} },
> {
<Pencil className='h-4 w-4' /> id: 'actions',
</Button> header: t('Actions'),
<Button className: 'text-right',
variant='ghost' cellClassName: 'text-right',
size='sm' cell: (provider) => (
onClick={() => setDeleteTarget(provider)} <div className='flex justify-end gap-1'>
> <Button
<Trash2 className='text-destructive h-4 w-4' /> variant='ghost'
</Button> size='sm'
</div> onClick={() => props.onEdit(provider)}
</TableCell> >
</TableRow> <Pencil className='h-4 w-4' />
))} </Button>
</TableBody> <Button
</Table> variant='ghost'
)} size='sm'
onClick={() => setDeleteTarget(provider)}
>
<Trash2 className='text-destructive h-4 w-4' />
</Button>
</div>
),
},
]}
/>
<ConfirmDialog <ConfirmDialog
open={!!deleteTarget} open={!!deleteTarget}
@@ -54,15 +54,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { StaticDataTable } from '@/components/data-table'
import { DateTimePicker } from '@/components/datetime-picker' import { DateTimePicker } from '@/components/datetime-picker'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
@@ -350,109 +343,104 @@ export function AnnouncementsSection({
/> />
</div> </div>
<div className='rounded-md border'> <StaticDataTable
<Table> data={sortedAnnouncements}
<TableHeader> getRowKey={(announcement) => announcement.id}
<TableRow> emptyContent={t(
<TableHead className='w-12'> 'No announcements yet. Click "Add Announcement" to create one.'
<Checkbox )}
checked={ columns={[
selectedIds.length === announcements.length && {
announcements.length > 0 id: 'select',
} header: (
onCheckedChange={toggleSelectAll} <Checkbox
/> checked={
</TableHead> selectedIds.length === announcements.length &&
<TableHead>{t('Content')}</TableHead> announcements.length > 0
<TableHead>{t('Publish Date')}</TableHead> }
<TableHead>{t('Type')}</TableHead> onCheckedChange={toggleSelectAll}
<TableHead>{t('Extra')}</TableHead> />
<TableHead className='w-32'>{t('Actions')}</TableHead> ),
</TableRow> className: 'w-12',
</TableHeader> cell: (announcement) => (
<TableBody> <Checkbox
{sortedAnnouncements.length === 0 ? ( checked={selectedIds.includes(announcement.id)}
<TableRow> onCheckedChange={(checked) =>
<TableCell colSpan={6} className='h-24 text-center'> toggleSelectOne(announcement.id, checked as boolean)
{t( }
'No announcements yet. Click "Add Announcement" to create one.' />
),
},
{
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'
)} )}
</TableCell> </span>
</TableRow> </div>
) : ( ),
sortedAnnouncements.map((announcement) => ( },
<TableRow key={announcement.id}> {
<TableCell> id: 'type',
<Checkbox header: t('Type'),
checked={selectedIds.includes(announcement.id)} cell: (announcement) => (
onCheckedChange={(checked) => <StatusBadge
toggleSelectOne(announcement.id, checked as boolean) label={
} typeOptions.find((opt) => opt.value === announcement.type)
/> ?.label
</TableCell> }
<TableCell variant={
className='max-w-xs truncate' typeOptions.find((opt) => opt.value === announcement.type)
title={announcement.content} ?.badgeVariant ?? 'neutral'
> }
{announcement.content} copyable={false}
</TableCell> />
<TableCell> ),
<div className='flex flex-col gap-1'> },
<span className='text-sm font-medium'> {
{getRelativeTime(announcement.publishDate)} id: 'extra',
</span> header: t('Extra'),
<span className='text-muted-foreground text-xs'> cellClassName: 'text-muted-foreground max-w-xs truncate',
{dayjs(announcement.publishDate).format( cell: (announcement) => announcement.extra || '-',
'YYYY-MM-DD HH:mm:ss' },
)} {
</span> id: 'actions',
</div> header: t('Actions'),
</TableCell> className: 'w-32',
<TableCell> cell: (announcement) => (
<StatusBadge <div className='flex gap-2'>
label={ <Button
typeOptions.find( onClick={() => handleEdit(announcement)}
(opt) => opt.value === announcement.type size='sm'
)?.label variant='ghost'
} >
variant={ <Edit className='h-4 w-4' />
typeOptions.find( </Button>
(opt) => opt.value === announcement.type <Button
)?.badgeVariant ?? 'neutral' onClick={() => handleDelete(announcement)}
} size='sm'
copyable={false} variant='ghost'
/> >
</TableCell> <Trash2 className='h-4 w-4' />
<TableCell </Button>
className='text-muted-foreground max-w-xs truncate' </div>
title={announcement.extra} ),
> },
{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>
</Table>
</div>
</div> </div>
<Dialog <Dialog
@@ -54,14 +54,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { import { StaticDataTable } from '@/components/data-table'
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { SettingsSwitchField } from '../components/settings-form-layout' import { SettingsSwitchField } from '../components/settings-form-layout'
@@ -306,101 +299,98 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
/> />
</div> </div>
<div className='rounded-md border'> <StaticDataTable
<Table> data={apiInfoList}
<TableHeader> getRowKey={(apiInfo) => apiInfo.id}
<TableRow> emptyContent={t('No API Domains yet. Click "Add API" to create one.')}
<TableHead className='w-12'> columns={[
<Checkbox {
checked={ id: 'select',
selectedIds.length === apiInfoList.length && header: (
apiInfoList.length > 0 <Checkbox
} checked={
onCheckedChange={toggleSelectAll} selectedIds.length === apiInfoList.length &&
apiInfoList.length > 0
}
onCheckedChange={toggleSelectAll}
/>
),
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)}`}
/> />
</TableHead> <span className='text-sm capitalize'>{apiInfo.color}</span>
<TableHead>{t('URL')}</TableHead> </div>
<TableHead>{t('Route')}</TableHead> ),
<TableHead>{t('Description')}</TableHead> },
<TableHead>{t('Color')}</TableHead> {
<TableHead className='w-32'>{t('Actions')}</TableHead> id: 'actions',
</TableRow> header: t('Actions'),
</TableHeader> className: 'w-32',
<TableBody> cell: (apiInfo) => (
{apiInfoList.length === 0 ? ( <div className='flex gap-2'>
<TableRow> <Button
<TableCell colSpan={6} className='h-24 text-center'> onClick={() => handleEdit(apiInfo)}
{t('No API Domains yet. Click "Add API" to create one.')} size='sm'
</TableCell> variant='ghost'
</TableRow> >
) : ( <Edit className='h-4 w-4' />
apiInfoList.map((apiInfo) => ( </Button>
<TableRow key={apiInfo.id}> <Button
<TableCell> onClick={() => handleDelete(apiInfo)}
<Checkbox size='sm'
checked={selectedIds.includes(apiInfo.id)} variant='ghost'
onCheckedChange={(checked) => >
toggleSelectOne(apiInfo.id, checked as boolean) <Trash2 className='h-4 w-4' />
} </Button>
/> </div>
</TableCell> ),
<TableCell },
className='max-w-xs truncate font-mono text-sm' ]}
title={apiInfo.url} />
>
<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}
>
{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>
</Table>
</div>
</div> </div>
<Dialog <Dialog
@@ -21,14 +21,7 @@ import { Pencil, Plus, Search, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { import { StaticDataTable } from '@/components/data-table'
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { safeJsonParseWithValidation } from '../utils/json-parser' import { safeJsonParseWithValidation } from '../utils/json-parser'
import { isArray } from '../utils/json-validators' import { isArray } from '../utils/json-validators'
import { ChatDialog, type ChatEntryData } from './chat-dialog' import { ChatDialog, type ChatEntryData } from './chat-dialog'
@@ -149,55 +142,55 @@ export function ChatSettingsVisualEditor({
</Button> </Button>
</div> </div>
{filteredChats.length === 0 ? ( <StaticDataTable
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'> data={filteredChats}
{searchText getRowKey={(chat) => chat.name}
emptyContent={
searchText
? t('No chat presets match your search') ? t('No chat presets match your search')
: t( : t(
'No chat presets configured. Click "Add chat preset" to get started.' 'No chat presets configured. Click "Add chat preset" to get started.'
)} )
</div> }
) : ( columns={[
<div className='rounded-md border'> {
<Table> id: 'name',
<TableHeader> header: t('Chat Client Name'),
<TableRow> cellClassName: 'font-medium',
<TableHead>{t('Chat Client Name')}</TableHead> cell: (chat) => chat.name,
<TableHead>{t('URL')}</TableHead> },
<TableHead className='text-right'>{t('Actions')}</TableHead> {
</TableRow> id: 'url',
</TableHeader> header: t('URL'),
<TableBody> cellClassName: 'max-w-md truncate font-mono text-sm',
{filteredChats.map((chat) => ( cell: (chat) => chat.url,
<TableRow key={chat.name}> },
<TableCell className='font-medium'>{chat.name}</TableCell> {
<TableCell className='max-w-md truncate font-mono text-sm'> id: 'actions',
{chat.url} header: t('Actions'),
</TableCell> className: 'text-right',
<TableCell className='text-right'> cellClassName: 'text-right',
<div className='flex justify-end gap-2'> cell: (chat) => (
<Button <div className='flex justify-end gap-2'>
variant='ghost' <Button
size='sm' variant='ghost'
onClick={() => handleEdit(chat)} size='sm'
> onClick={() => handleEdit(chat)}
<Pencil className='h-4 w-4' /> >
</Button> <Pencil className='h-4 w-4' />
<Button </Button>
variant='ghost' <Button
size='sm' variant='ghost'
onClick={() => handleDelete(chat.name)} size='sm'
> onClick={() => handleDelete(chat.name)}
<Trash2 className='h-4 w-4' /> >
</Button> <Trash2 className='h-4 w-4' />
</div> </Button>
</TableCell> </div>
</TableRow> ),
))} },
</TableBody> ]}
</Table> />
</div>
)}
<ChatDialog <ChatDialog
open={dialogOpen} open={dialogOpen}
@@ -45,15 +45,8 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../components/settings-form-layout' import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section' import { SettingsSection } from '../components/settings-section'
@@ -269,78 +262,68 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
/> />
</div> </div>
<div className='rounded-md border'> <StaticDataTable
<Table> data={faqList}
<TableHeader> getRowKey={(faq) => faq.id}
<TableRow> emptyContent={t('No FAQ entries yet. Click "Add FAQ" to create one.')}
<TableHead className='w-12'> columns={[
<Checkbox {
checked={ id: 'select',
selectedIds.length === faqList.length && header: (
faqList.length > 0 <Checkbox
} checked={
onCheckedChange={toggleSelectAll} selectedIds.length === faqList.length && faqList.length > 0
/> }
</TableHead> onCheckedChange={toggleSelectAll}
<TableHead>{t('Question')}</TableHead> />
<TableHead>{t('Answer')}</TableHead> ),
<TableHead className='w-32'>{t('Actions')}</TableHead> className: 'w-12',
</TableRow> cell: (faq) => (
</TableHeader> <Checkbox
<TableBody> checked={selectedIds.includes(faq.id)}
{faqList.length === 0 ? ( onCheckedChange={(checked) =>
<TableRow> toggleSelectOne(faq.id, checked as boolean)
<TableCell colSpan={4} className='h-24 text-center'> }
{t('No FAQ entries yet. Click "Add FAQ" to create one.')} />
</TableCell> ),
</TableRow> },
) : ( {
faqList.map((faq) => ( id: 'question',
<TableRow key={faq.id}> header: t('Question'),
<TableCell> cellClassName: 'max-w-xs truncate font-medium',
<Checkbox cell: (faq) => faq.question,
checked={selectedIds.includes(faq.id)} },
onCheckedChange={(checked) => {
toggleSelectOne(faq.id, checked as boolean) id: 'answer',
} header: t('Answer'),
/> cellClassName: 'text-muted-foreground max-w-md truncate',
</TableCell> cell: (faq) => faq.answer,
<TableCell },
className='max-w-xs truncate font-medium' {
title={faq.question} id: 'actions',
> header: t('Actions'),
{faq.question} className: 'w-32',
</TableCell> cell: (faq) => (
<TableCell <div className='flex gap-2'>
className='text-muted-foreground max-w-md truncate' <Button
title={faq.answer} onClick={() => handleEdit(faq)}
> size='sm'
{faq.answer} variant='ghost'
</TableCell> >
<TableCell> <Edit className='h-4 w-4' />
<div className='flex gap-2'> </Button>
<Button <Button
onClick={() => handleEdit(faq)} onClick={() => handleDelete(faq)}
size='sm' size='sm'
variant='ghost' variant='ghost'
> >
<Edit className='h-4 w-4' /> <Trash2 className='h-4 w-4' />
</Button> </Button>
<Button </div>
onClick={() => handleDelete(faq)} ),
size='sm' },
variant='ghost' ]}
> />
<Trash2 className='h-4 w-4' />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div> </div>
<Dialog <Dialog
@@ -45,14 +45,7 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { import { StaticDataTable } from '@/components/data-table'
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../components/settings-form-layout' import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section' import { SettingsSection } from '../components/settings-section'
@@ -278,80 +271,77 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
/> />
</div> </div>
<div className='rounded-md border'> <StaticDataTable
<Table> data={groups}
<TableHeader> getRowKey={(group) => group.id}
<TableRow> emptyContent={t(
<TableHead className='w-12'> 'No Uptime Kuma groups yet. Click "Add Group" to create one.'
<Checkbox )}
checked={ columns={[
selectedIds.length === groups.length && groups.length > 0 {
} id: 'select',
onCheckedChange={toggleSelectAll} header: (
/> <Checkbox
</TableHead> checked={
<TableHead>{t('Category Name')}</TableHead> selectedIds.length === groups.length && groups.length > 0
<TableHead>{t('Uptime Kuma URL')}</TableHead> }
<TableHead>{t('Status Page Slug')}</TableHead> onCheckedChange={toggleSelectAll}
<TableHead className='w-32'>{t('Actions')}</TableHead> />
</TableRow> ),
</TableHeader> className: 'w-12',
<TableBody> cell: (group) => (
{groups.length === 0 ? ( <Checkbox
<TableRow> checked={selectedIds.includes(group.id)}
<TableCell colSpan={5} className='h-24 text-center'> onCheckedChange={(checked) =>
{t( toggleSelectOne(group.id, checked as boolean)
'No Uptime Kuma groups yet. Click "Add Group" to create one.' }
)} />
</TableCell> ),
</TableRow> },
) : ( {
groups.map((group) => ( id: 'category',
<TableRow key={group.id}> header: t('Category Name'),
<TableCell> cellClassName: 'font-medium',
<Checkbox cell: (group) => group.categoryName,
checked={selectedIds.includes(group.id)} },
onCheckedChange={(checked) => {
toggleSelectOne(group.id, checked as boolean) id: 'url',
} header: t('Uptime Kuma URL'),
/> cellClassName:
</TableCell> 'text-primary max-w-xs truncate font-mono text-sm',
<TableCell className='font-medium'> cell: (group) => group.url,
{group.categoryName} },
</TableCell> {
<TableCell id: 'slug',
className='text-primary max-w-xs truncate font-mono text-sm' header: t('Status Page Slug'),
title={group.url} cellClassName: 'text-muted-foreground font-mono text-sm',
> cell: (group) => group.slug,
{group.url} },
</TableCell> {
<TableCell className='text-muted-foreground font-mono text-sm'> id: 'actions',
{group.slug} header: t('Actions'),
</TableCell> className: 'w-32',
<TableCell> cell: (group) => (
<div className='flex gap-2'> <div className='flex gap-2'>
<Button <Button
onClick={() => handleEdit(group)} onClick={() => handleEdit(group)}
size='sm' size='sm'
variant='ghost' variant='ghost'
> >
<Edit className='h-4 w-4' /> <Edit className='h-4 w-4' />
</Button> </Button>
<Button <Button
onClick={() => handleDelete(group)} onClick={() => handleDelete(group)}
size='sm' size='sm'
variant='ghost' variant='ghost'
> >
<Trash2 className='h-4 w-4' /> <Trash2 className='h-4 w-4' />
</Button> </Button>
</div> </div>
</TableCell> ),
</TableRow> },
)) ]}
)} />
</TableBody>
</Table>
</div>
</div> </div>
<Dialog <Dialog
@@ -31,15 +31,8 @@ import {
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
import { StatusBadge, StatusBadgeList } from '@/components/status-badge' import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
import { SettingsSwitchField } from '../../components/settings-form-layout' import { SettingsSwitchField } from '../../components/settings-form-layout'
@@ -546,118 +539,117 @@ export function ChannelAffinitySection(props: Props) {
{/* Rules Table or JSON Editor */} {/* Rules Table or JSON Editor */}
{editMode === 'visual' ? ( {editMode === 'visual' ? (
<div className='overflow-x-auto rounded-md border'> <StaticDataTable
<Table> tableClassName='min-w-max'
<TableHeader> data={rules}
<TableRow> emptyClassName='text-muted-foreground py-8'
<TableHead>{t('Name')}</TableHead> emptyContent={t('No rules yet')}
<TableHead>{t('Model Regex')}</TableHead> columns={[
<TableHead>{t('Key Sources')}</TableHead> {
<TableHead>{t('TTL')}</TableHead> id: 'name',
<TableHead>{t('Retry')}</TableHead> header: t('Name'),
<TableHead>{t('Scope')}</TableHead> cellClassName: 'font-medium',
<TableHead>{t('Cache')}</TableHead> cell: (rule) => rule.name || '-',
<TableHead className='text-right'>{t('Actions')}</TableHead> },
</TableRow> {
</TableHeader> id: 'model-regex',
<TableBody> header: t('Model Regex'),
{rules.length === 0 ? ( cell: (rule) => <RuleBadgeList items={rule.model_regex || []} />,
<TableRow> },
<TableCell {
colSpan={8} id: 'key-sources',
className='text-muted-foreground py-8 text-center' 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)
}}
> >
{t('No rules yet')} <Edit className='h-3 w-3' />
</TableCell> </Button>
</TableRow> <Button
) : ( variant='ghost'
rules.map((rule, idx) => ( size='icon'
<TableRow key={idx}> className='h-7 w-7'
<TableCell className='font-medium'> onClick={() => handleDeleteRule(idx)}
{rule.name || '-'} >
</TableCell> <Trash2 className='h-3 w-3' />
<TableCell> </Button>
<RuleBadgeList items={rule.model_regex || []} /> </div>
</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>
</Table>
</div>
) : ( ) : (
<div className='grid gap-1.5'> <div className='grid gap-1.5'>
<Label>{t('Rules JSON')}</Label> <Label>{t('Rules JSON')}</Label>
@@ -20,14 +20,7 @@ import { useState, useMemo } from 'react'
import { Pencil, Plus, Trash2 } from 'lucide-react' import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import { StaticDataTable } from '@/components/data-table'
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { safeJsonParseWithValidation } from '../utils/json-parser' import { safeJsonParseWithValidation } from '../utils/json-parser'
import { isObjectRecord } from '../utils/json-validators' import { isObjectRecord } from '../utils/json-validators'
@@ -147,71 +140,78 @@ export function AmountDiscountVisualEditor({
) : ( ) : (
<div className='rounded-md border'> <div className='rounded-md border'>
{/* Desktop table view */} {/* Desktop table view */}
<div className='hidden sm:block'> <StaticDataTable
<Table> className='hidden rounded-none border-0 sm:block'
<TableHeader> data={discounts}
<TableRow> getRowKey={(discount) => discount.amount}
<TableHead>{t('Recharge Amount')}</TableHead> columns={[
<TableHead>{t('Discount Rate')}</TableHead> {
<TableHead>{t('Discount')}</TableHead> id: 'amount',
<TableHead className='text-right'>{t('Actions')}</TableHead> header: t('Recharge Amount'),
</TableRow> cell: (discount) => (
</TableHeader> <span className='font-mono text-sm'>
<TableBody> ${discount.amount}
{discounts.map((discount) => ( </span>
<TableRow key={discount.amount}> ),
<TableCell> },
<span className='font-mono text-sm'> {
${discount.amount} id: 'discount-rate',
</span> header: t('Discount Rate'),
</TableCell> cell: (discount) => (
<TableCell> <code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'> {discount.discountRate.toFixed(2)}
{discount.discountRate.toFixed(2)} </code>
</code> ),
</TableCell> },
<TableCell> {
<StatusBadge id: 'discount',
variant={discount.discountRate < 1 ? 'info' : 'neutral'} header: t('Discount'),
className='font-mono' cell: (discount) => (
copyable={false} <StatusBadge
> variant={discount.discountRate < 1 ? 'info' : 'neutral'}
{formatPercentage(discount.discountRate)} {t('off')} className='font-mono'
</StatusBadge> copyable={false}
</TableCell> >
<TableCell className='text-right'> {formatPercentage(discount.discountRate)} {t('off')}
<div className='flex justify-end gap-2'> </StatusBadge>
<Button ),
type='button' },
variant='ghost' {
size='sm' id: 'actions',
onClick={(e) => { header: t('Actions'),
e.preventDefault() className: 'text-right',
e.stopPropagation() cellClassName: 'text-right',
handleEdit(discount) cell: (discount) => (
}} <div className='flex justify-end gap-2'>
> <Button
<Pencil className='h-4 w-4' /> type='button'
</Button> variant='ghost'
<Button size='sm'
type='button' onClick={(e) => {
variant='ghost' e.preventDefault()
size='sm' e.stopPropagation()
onClick={(e) => { handleEdit(discount)
e.preventDefault() }}
e.stopPropagation() >
handleDelete(discount.amount) <Pencil className='h-4 w-4' />
}} </Button>
> <Button
<Trash2 className='h-4 w-4' /> type='button'
</Button> variant='ghost'
</div> size='sm'
</TableCell> onClick={(e) => {
</TableRow> e.preventDefault()
))} e.stopPropagation()
</TableBody> handleDelete(discount.amount)
</Table> }}
</div> >
<Trash2 className='h-4 w-4' />
</Button>
</div>
),
},
]}
/>
{/* Mobile card view */} {/* Mobile card view */}
<div className='divide-y sm:hidden'> <div className='divide-y sm:hidden'>
@@ -21,14 +21,7 @@ import { Pencil, Plus, Search, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { import { StaticDataTable } from '@/components/data-table'
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { import {
formatCreemPrice, formatCreemPrice,
formatQuotaShort, formatQuotaShort,
@@ -183,71 +176,80 @@ export function CreemProductsVisualEditor({
) : ( ) : (
<div className='rounded-md border'> <div className='rounded-md border'>
{/* Desktop table view */} {/* Desktop table view */}
<div className='hidden md:block'> <StaticDataTable
<Table> className='hidden rounded-none border-0 md:block'
<TableHeader> data={filteredProducts}
<TableRow> getRowKey={(product) => product.productId}
<TableHead>{t('Name')}</TableHead> columns={[
<TableHead>{t('Product ID')}</TableHead> {
<TableHead>{t('Price')}</TableHead> id: 'name',
<TableHead>{t('Quota')}</TableHead> header: t('Name'),
<TableHead className='text-right'>{t('Actions')}</TableHead> cellClassName: 'font-medium',
</TableRow> cell: (product) => product.name,
</TableHeader> },
<TableBody> {
{filteredProducts.map((product) => ( id: 'product-id',
<TableRow key={product.productId}> header: t('Product ID'),
<TableCell className='font-medium'> cell: (product) => (
{product.name} <code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
</TableCell> {product.productId}
<TableCell> </code>
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'> ),
{product.productId} },
</code> {
</TableCell> id: 'price',
<TableCell> header: t('Price'),
<span className='font-mono text-sm'> cell: (product) => (
{formatCreemPrice(product.price, product.currency)} <span className='font-mono text-sm'>
</span> {formatCreemPrice(product.price, product.currency)}
</TableCell> </span>
<TableCell> ),
<span className='font-mono text-sm'> },
{formatQuotaShort(product.quota)} {
</span> id: 'quota',
</TableCell> header: t('Quota'),
<TableCell className='text-right'> cell: (product) => (
<div className='flex justify-end gap-2'> <span className='font-mono text-sm'>
<Button {formatQuotaShort(product.quota)}
type='button' </span>
variant='ghost' ),
size='sm' },
onClick={(e) => { {
e.preventDefault() id: 'actions',
e.stopPropagation() header: t('Actions'),
handleEdit(product) className: 'text-right',
}} cellClassName: 'text-right',
> cell: (product) => (
<Pencil className='h-4 w-4' /> <div className='flex justify-end gap-2'>
</Button> <Button
<Button type='button'
type='button' variant='ghost'
variant='ghost' size='sm'
size='sm' onClick={(e) => {
onClick={(e) => { e.preventDefault()
e.preventDefault() e.stopPropagation()
e.stopPropagation() handleEdit(product)
handleDelete(product) }}
}} >
> <Pencil className='h-4 w-4' />
<Trash2 className='h-4 w-4' /> </Button>
</Button> <Button
</div> type='button'
</TableCell> variant='ghost'
</TableRow> size='sm'
))} onClick={(e) => {
</TableBody> e.preventDefault()
</Table> e.stopPropagation()
</div> handleDelete(product)
}}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
),
},
]}
/>
{/* Mobile card view */} {/* Mobile card view */}
<div className='divide-y md:hidden'> <div className='divide-y md:hidden'>
@@ -27,13 +27,8 @@ import {
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover' } from '@/components/ui/popover'
import { import {
Table, StaticDataTable,
TableBody, } from '@/components/data-table'
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { safeJsonParseWithValidation } from '../utils/json-parser' import { safeJsonParseWithValidation } from '../utils/json-parser'
import { isArray } from '../utils/json-validators' import { isArray } from '../utils/json-validators'
import { import {
@@ -291,88 +286,95 @@ export function PaymentMethodsVisualEditor({
) : ( ) : (
<div className='rounded-md border'> <div className='rounded-md border'>
{/* Desktop table view */} {/* Desktop table view */}
<div className='hidden md:block'> <StaticDataTable
<Table> className='hidden rounded-none border-0 md:block'
<TableHeader> data={filteredMethods}
<TableRow> getRowKey={(method, index) => `${method.type}-${index}`}
<TableHead>{t('Name')}</TableHead> columns={[
<TableHead>{t('Type')}</TableHead> {
<TableHead>{t('Color')}</TableHead> id: 'name',
<TableHead>{t('Min Top-up')}</TableHead> header: t('Name'),
<TableHead className='text-right'>{t('Actions')}</TableHead> cellClassName: 'font-medium',
</TableRow> cell: (method) => method.name,
</TableHeader> },
<TableBody> {
{filteredMethods.map((method, index) => { 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) const colorPreview = getColorPreview(method.color)
return ( return (
<TableRow key={`${method.type}-${index}`}> <div className='flex items-center gap-2'>
<TableCell className='font-medium'> {colorPreview && (
{method.name} <div
</TableCell> className='size-5 shrink-0 rounded border'
<TableCell> style={{ backgroundColor: colorPreview }}
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'> />
{method.type} )}
</code> <span className='text-muted-foreground truncate font-mono text-sm'>
</TableCell> {method.color}
<TableCell> </span>
<div className='flex items-center gap-2'> </div>
{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>
)}
</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> },
</Table> {
</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 */} {/* Mobile card view */}
<div className='divide-y md:hidden'> <div className='divide-y md:hidden'>
@@ -25,15 +25,8 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../components/settings-form-layout' import { SettingsSwitchField } from '../components/settings-form-layout'
@@ -333,76 +326,74 @@ export function WaffoSettingsSection({
</Button> </Button>
</div> </div>
<div className='rounded-md border'> <StaticDataTable
<Table> data={payMethods}
<TableHeader> emptyClassName='text-muted-foreground py-8'
<TableRow> emptyContent={t('No payment methods configured')}
<TableHead>{t('Display name')}</TableHead> columns={[
<TableHead>{t('Icon')}</TableHead> {
<TableHead>{t('Payment method type')}</TableHead> id: 'name',
<TableHead>{t('Payment method name')}</TableHead> header: t('Display name'),
<TableHead className='text-right'>{t('Actions')}</TableHead> cell: (m) => m.name,
</TableRow> },
</TableHeader> {
<TableBody> id: 'icon',
{payMethods.length === 0 ? ( header: t('Icon'),
<TableRow> cell: (m) =>
<TableCell m.icon ? (
colSpan={5} <img
className='text-muted-foreground py-8 text-center' 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)}
> >
{t('No payment methods configured')} <Pencil className='h-3 w-3' />
</TableCell> </Button>
</TableRow> <Button
) : ( type='button'
payMethods.map((m, idx) => ( variant='ghost'
<TableRow key={idx}> size='icon'
<TableCell>{m.name}</TableCell> className='h-7 w-7'
<TableCell> onClick={() =>
{m.icon ? ( onPayMethodsChange((prev) =>
<img prev.filter((_, i) => i !== idx)
src={m.icon} )
alt={m.name} }
className='h-6 w-6 rounded object-contain' >
/> <Trash2 className='h-3 w-3' />
) : ( </Button>
<span className='text-muted-foreground'>-</span> </div>
)} ),
</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>
</Table>
</div>
</div> </div>
<Dialog <Dialog
@@ -17,15 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { import { type ColumnDef, type RowSelectionState } from '@tanstack/react-table'
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
useReactTable,
type ColumnDef,
type RowSelectionState,
} from '@tanstack/react-table'
import { Search } from 'lucide-react' import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -40,14 +32,10 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { import {
Table, DataTablePagination,
TableBody, DataTableView,
TableCell, useDataTable,
TableHead, } from '@/components/data-table'
TableHeader,
TableRow,
} from '@/components/ui/table'
import { DataTablePagination } from '@/components/data-table/pagination'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import type { UpstreamChannel } from '../types' import type { UpstreamChannel } from '../types'
@@ -295,23 +283,16 @@ export function ChannelSelectorDialog({
}) })
}, [filteredChannels]) }, [filteredChannels])
const table = useReactTable({ const { table } = useDataTable({
data: sortedChannels, data: sortedChannels,
columns, columns,
state: { rowSelection,
rowSelection,
},
getRowId: (row) => row.id.toString(), getRowId: (row) => row.id.toString(),
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), initialPagination: { pageIndex: 0, pageSize: 10 },
getFilteredRowModel: getFilteredRowModel(), withSortedRowModel: false,
getPaginationRowModel: getPaginationRowModel(), withFacetedRowModel: false,
initialState: {
pagination: {
pageSize: 10,
},
},
}) })
const handleConfirm = () => { const handleConfirm = () => {
@@ -355,54 +336,12 @@ export function ChannelSelectorDialog({
</div> </div>
</div> </div>
<div className='flex-1 overflow-auto rounded-md border'> <DataTableView
<Table> table={table}
<TableHeader> containerClassName='flex-1 overflow-auto rounded-md'
{table.getHeaderGroups().map((headerGroup) => ( emptyContent={t('No channels found')}
<TableRow key={headerGroup.id}> emptyCellClassName='h-24 text-center'
{headerGroup.headers.map((header) => ( />
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
{t('No channels found')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} /> <DataTablePagination table={table} />
</div> </div>
@@ -28,13 +28,8 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { import {
Table, StaticDataTable,
TableBody, } from '@/components/data-table'
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
export type ConflictItem = { export type ConflictItem = {
channel: string channel: string
@@ -71,40 +66,42 @@ export function ConflictConfirmDialog({
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className='max-h-96 overflow-y-auto rounded-md border'> <StaticDataTable
<Table> className='max-h-96 overflow-y-auto'
<TableHeader> data={conflicts}
<TableRow> columns={[
<TableHead>{t('Channel')}</TableHead> {
<TableHead>{t('Model')}</TableHead> id: 'channel',
<TableHead>{t('Current Billing')}</TableHead> header: t('Channel'),
<TableHead>{t('Change To')}</TableHead> cellClassName: 'font-medium',
</TableRow> cell: (conflict) => conflict.channel,
</TableHeader> },
<TableBody> {
{conflicts.map((conflict, index) => ( id: 'model',
<TableRow key={index}> header: t('Model'),
<TableCell className='font-medium'> cellClassName: 'font-mono text-sm',
{conflict.channel} cell: (conflict) => conflict.model,
</TableCell> },
<TableCell className='font-mono text-sm'> {
{conflict.model} id: 'current',
</TableCell> header: t('Current Billing'),
<TableCell> cell: (conflict) => (
<pre className='text-sm whitespace-pre-wrap'> <pre className='text-sm whitespace-pre-wrap'>
{conflict.current} {conflict.current}
</pre> </pre>
</TableCell> ),
<TableCell> },
<pre className='text-sm whitespace-pre-wrap'> {
{conflict.newVal} id: 'new',
</pre> header: t('Change To'),
</TableCell> cell: (conflict) => (
</TableRow> <pre className='text-sm whitespace-pre-wrap'>
))} {conflict.newVal}
</TableBody> </pre>
</Table> ),
</div> },
]}
/>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}> <AlertDialogCancel disabled={isLoading}>
@@ -35,14 +35,7 @@ import {
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { import { StaticDataTable } from '@/components/data-table'
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
import { safeJsonParse } from '../utils/json-parser' import { safeJsonParse } from '../utils/json-parser'
@@ -427,54 +420,51 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
{t('Add group')} {t('Add group')}
</Button> </Button>
{topupRatioList.length > 0 && ( {topupRatioList.length > 0 && (
<div className='rounded-md border'> <StaticDataTable
<Table> data={topupRatioList}
<TableHeader> getRowKey={(group) => group.name}
<TableRow> columns={[
<TableHead>{t('Group name')}</TableHead> {
<TableHead>{t('Multiplier')}</TableHead> id: 'group',
<TableHead className='text-right'> header: t('Group name'),
{t('Actions')} cellClassName: 'font-medium',
</TableHead> cell: (group) => group.name,
</TableRow> },
</TableHeader> {
<TableBody> id: 'multiplier',
{topupRatioList.map((group) => ( header: t('Multiplier'),
<TableRow key={group.name}> cell: (group) => group.value,
<TableCell className='font-medium'> },
{group.name} {
</TableCell> id: 'actions',
<TableCell>{group.value}</TableCell> header: t('Actions'),
<TableCell className='text-right'> className: 'text-right',
<div className='flex justify-end gap-2'> cellClassName: 'text-right',
<Button cell: (group) => (
variant='ghost' <div className='flex justify-end gap-2'>
size='sm' <Button
onClick={() => variant='ghost'
handleSimpleEdit('topupGroupRatio', group) size='sm'
} onClick={() =>
> handleSimpleEdit('topupGroupRatio', group)
<Pencil className='h-4 w-4' /> }
</Button> >
<Button <Pencil className='h-4 w-4' />
variant='ghost' </Button>
size='sm' <Button
onClick={() => variant='ghost'
handleSimpleDelete( size='sm'
'topupGroupRatio', onClick={() =>
group.name handleSimpleDelete('topupGroupRatio', group.name)
) }
} >
> <Trash2 className='h-4 w-4' />
<Trash2 className='h-4 w-4' /> </Button>
</Button> </div>
</div> ),
</TableCell> },
</TableRow> ]}
))} />
</TableBody>
</Table>
</div>
)} )}
</div> </div>
</CardContent> </CardContent>
@@ -541,55 +531,58 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
<CollapsibleContent> <CollapsibleContent>
{userGroupData.overrides.length > 0 && ( {userGroupData.overrides.length > 0 && (
<div className='border-t'> <div className='border-t'>
<Table> <StaticDataTable
<TableHeader> className='rounded-none border-0'
<TableRow> data={userGroupData.overrides}
<TableHead>{t('Target group')}</TableHead> getRowKey={(override) => override.targetGroup}
<TableHead>{t('Ratio')}</TableHead> columns={[
<TableHead className='text-right'> {
{t('Actions')} id: 'target-group',
</TableHead> header: t('Target group'),
</TableRow> cellClassName: 'font-medium',
</TableHeader> cell: (override) => override.targetGroup,
<TableBody> },
{userGroupData.overrides.map((override) => ( {
<TableRow key={override.targetGroup}> id: 'ratio',
<TableCell className='font-medium'> header: t('Ratio'),
{override.targetGroup} cell: (override) => override.ratio,
</TableCell> },
<TableCell>{override.ratio}</TableCell> {
<TableCell className='text-right'> id: 'actions',
<div className='flex justify-end gap-2'> header: t('Actions'),
<Button className: 'text-right',
variant='ghost' cellClassName: 'text-right',
size='sm' cell: (override) => (
onClick={() => <div className='flex justify-end gap-2'>
handleOverrideEdit( <Button
userGroupData.userGroup, variant='ghost'
override size='sm'
) onClick={() =>
} handleOverrideEdit(
> userGroupData.userGroup,
<Pencil className='h-4 w-4' /> override
</Button> )
<Button }
variant='ghost' >
size='sm' <Pencil className='h-4 w-4' />
onClick={() => </Button>
handleOverrideDelete( <Button
userGroupData.userGroup, variant='ghost'
override.targetGroup size='sm'
) onClick={() =>
} handleOverrideDelete(
> userGroupData.userGroup,
<Trash2 className='h-4 w-4' /> override.targetGroup
</Button> )
</div> }
</TableCell> >
</TableRow> <Trash2 className='h-4 w-4' />
))} </Button>
</TableBody> </div>
</Table> ),
},
]}
/>
</div> </div>
)} )}
</CollapsibleContent> </CollapsibleContent>
@@ -858,106 +851,99 @@ function GroupPricingTable({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className='space-y-3'> <div className='space-y-3'>
<div className='overflow-hidden rounded-md border'> <StaticDataTable
<Table> data={rows}
<TableHeader> getRowKey={(row) => row._id}
<TableRow> emptyClassName='text-muted-foreground h-20 text-sm'
<TableHead className='min-w-40'>{t('Group name')}</TableHead> emptyContent={t('No groups yet. Add a group to get started.')}
<TableHead className='w-28'>{t('Ratio')}</TableHead> columns={[
<TableHead className='w-28 text-center'> {
{t('User selectable')} id: 'group',
</TableHead> header: t('Group name'),
<TableHead className='min-w-56'>{t('Description')}</TableHead> className: 'min-w-40',
<TableHead className='w-16 text-right'> cell: (row) => (
{t('Actions')} <Input
</TableHead> value={row.name}
</TableRow> onChange={(event) =>
</TableHeader> updateRow(row._id, 'name', event.target.value)
<TableBody> }
{rows.length === 0 ? ( aria-invalid={duplicateNames.includes(row.name.trim())}
<TableRow> />
<TableCell ),
colSpan={5} },
className='text-muted-foreground h-20 text-center text-sm' {
> id: 'ratio',
{t('No groups yet. Add a group to get started.')} header: t('Ratio'),
</TableCell> className: 'w-28',
</TableRow> cell: (row) => (
) : ( <Input
rows.map((row) => ( type='number'
<TableRow key={row._id}> min={0}
<TableCell> step={0.1}
<Input value={String(row.ratio)}
value={row.name} onChange={(event) =>
onChange={(event) => updateRow(
updateRow(row._id, 'name', event.target.value) row._id,
} 'ratio',
aria-invalid={duplicateNames.includes( normalizeRatio(event.target.value)
row.name.trim() )
)} }
/> />
</TableCell> ),
<TableCell> },
<Input {
type='number' id: 'selectable',
min={0} header: t('User selectable'),
step={0.1} className: 'w-28 text-center',
value={String(row.ratio)} cell: (row) => (
onChange={(event) => <div className='flex justify-center'>
updateRow( <Checkbox
row._id, checked={row.selectable}
'ratio', onCheckedChange={(checked) =>
normalizeRatio(event.target.value) updateRow(row._id, 'selectable', checked === true)
) }
} aria-label={t('User selectable')}
/> />
</TableCell> </div>
<TableCell> ),
<div className='flex justify-center'> },
<Checkbox {
checked={row.selectable} id: 'description',
onCheckedChange={(checked) => header: t('Description'),
updateRow(row._id, 'selectable', checked === true) className: 'min-w-56',
} cell: (row) =>
aria-label={t('User selectable')} row.selectable ? (
/> <Input
</div> value={row.description}
</TableCell> placeholder={t('Group description')}
<TableCell> onChange={(event) =>
{row.selectable ? ( updateRow(row._id, 'description', event.target.value)
<Input }
value={row.description} />
placeholder={t('Group description')} ) : (
onChange={(event) => <span className='text-muted-foreground px-3 text-sm'>
updateRow( -
row._id, </span>
'description', ),
event.target.value },
) {
} id: 'actions',
/> header: t('Actions'),
) : ( className: 'w-16 text-right',
<span className='text-muted-foreground px-3 text-sm'> cellClassName: 'text-right',
- cell: (row) => (
</span> <Button
)} variant='ghost'
</TableCell> size='sm'
<TableCell className='text-right'> onClick={() => removeRow(row._id)}
<Button aria-label={t('Delete')}
variant='ghost' >
size='sm' <Trash2 className='h-4 w-4' />
onClick={() => removeRow(row._id)} </Button>
aria-label={t('Delete')} ),
> },
<Trash2 className='h-4 w-4' /> ]}
</Button> />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{duplicateNames.length > 0 && ( {duplicateNames.length > 0 && (
<p className='text-destructive text-sm'> <p className='text-destructive text-sm'>
@@ -71,6 +71,7 @@ export function buildModelRatioColumns({
), ),
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
size: 40,
meta: { label: t('Select') }, meta: { label: t('Select') },
}, },
{ {
@@ -79,16 +80,22 @@ export function buildModelRatioColumns({
<DataTableColumnHeader column={column} title={t('Model name')} /> <DataTableColumnHeader column={column} title={t('Model name')} />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<div className='flex items-center gap-2 font-medium'> <div className='flex min-w-0 items-center gap-2 font-medium'>
{row.getValue('name')} <span className='min-w-0 truncate'>{row.getValue('name')}</span>
{row.original.billingMode === 'tiered_expr' && ( {row.original.billingMode === 'tiered_expr' && (
<StatusBadge label={t('Tiered')} variant='info' copyable={false} /> <StatusBadge
label={t('Tiered')}
variant='info'
copyable={false}
className='shrink-0'
/>
)} )}
{row.original.hasConflict && ( {row.original.hasConflict && (
<StatusBadge <StatusBadge
label={t('Conflict')} label={t('Conflict')}
variant='danger' variant='danger'
copyable={false} copyable={false}
className='shrink-0'
/> />
)} )}
</div> </div>
@@ -119,11 +126,11 @@ export function buildModelRatioColumns({
<DataTableColumnHeader column={column} title={t('Price summary')} /> <DataTableColumnHeader column={column} title={t('Price summary')} />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<div className='flex min-w-[180px] flex-col gap-1'> <div className='flex min-w-0 flex-col gap-1'>
<span className='font-medium'> <span className='truncate font-medium'>
{getPriceSummary(row.original, t)} {getPriceSummary(row.original, t)}
</span> </span>
<span className='text-muted-foreground max-w-[320px] truncate text-xs'> <span className='text-muted-foreground truncate text-xs'>
{getPriceDetail(row.original, t)} {getPriceDetail(row.original, t)}
</span> </span>
</div> </div>
@@ -136,7 +143,7 @@ export function buildModelRatioColumns({
}, },
{ {
id: 'actions', id: 'actions',
header: () => <div className='text-right'>{t('Actions')}</div>, header: () => <div>{t('Actions')}</div>,
cell: ({ row }) => ( cell: ({ row }) => (
<div className='flex justify-end gap-2'> <div className='flex justify-end gap-2'>
<Button <Button
@@ -33,25 +33,19 @@ import {
type RowSelectionState, type RowSelectionState,
type VisibilityState, type VisibilityState,
type SortingState, type SortingState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table' } from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks' import { useMediaQuery } from '@/hooks'
import { Copy, Plus } from 'lucide-react' import { Copy, Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
DataTableBulkActions, DataTableBulkActions,
DataTableToolbar, DataTableToolbar,
DataTablePagination, DataTablePagination,
DataTableRow,
DataTableView,
useDataTable,
} from '@/components/data-table' } from '@/components/data-table'
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr' import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
import { safeJsonParse } from '../utils/json-parser' import { safeJsonParse } from '../utils/json-parser'
@@ -424,17 +418,15 @@ const ModelRatioVisualEditorComponent = forwardRef<
[handleEdit, handleDelete, t] [handleEdit, handleDelete, t]
) )
const table = useReactTable({ const { table } = useDataTable({
data: models, data: models,
columns, columns,
state: { sorting,
sorting, columnFilters,
columnFilters, globalFilter,
globalFilter, columnVisibility,
columnVisibility, pagination,
pagination, rowSelection,
rowSelection,
},
enableRowSelection: true, enableRowSelection: true,
onSortingChange: setSorting, onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters, onColumnFiltersChange: setColumnFilters,
@@ -443,12 +435,6 @@ const ModelRatioVisualEditorComponent = forwardRef<
onPaginationChange: setPagination, onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
autoResetPageIndex: false, autoResetPageIndex: false,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
globalFilterFn: (row, _columnId, filterValue) => { globalFilterFn: (row, _columnId, filterValue) => {
const searchValue = String(filterValue).toLowerCase() const searchValue = String(filterValue).toLowerCase()
return row.original.name.toLowerCase().includes(searchValue) return row.original.name.toLowerCase().includes(searchValue)
@@ -629,6 +615,8 @@ const ModelRatioVisualEditorComponent = forwardRef<
[editorOpen, persistPricingData] [editorOpen, persistPricingData]
) )
const hasRows = table.getRowModel().rows.length > 0
return ( return (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<div className='grid h-[clamp(720px,calc(100vh-12rem),900px)] min-h-0 gap-4 md:grid-cols-[minmax(300px,0.72fr)_minmax(520px,1.28fr)] xl:grid-cols-[minmax(320px,0.68fr)_minmax(640px,1.32fr)]'> <div className='grid h-[clamp(720px,calc(100vh-12rem),900px)] min-h-0 gap-4 md:grid-cols-[minmax(300px,0.72fr)_minmax(520px,1.28fr)] xl:grid-cols-[minmax(320px,0.68fr)_minmax(640px,1.32fr)]'>
@@ -667,82 +655,64 @@ const ModelRatioVisualEditorComponent = forwardRef<
} }
/> />
{table.getRowModel().rows.length === 0 ? ( {!hasRows ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
{table.getState().globalFilter {table.getState().globalFilter
? t('No models match your search') ? t('No models match your search')
: t('No models configured. Use Add model to get started.')} : t('No models configured. Use Add model to get started.')}
</div> </div>
) : ( ) : (
<div className='min-h-0 flex-1 overflow-auto rounded-md border'> <DataTableView
<table className='w-full caption-bottom text-sm tabular-nums'> table={table}
<thead> containerClassName='min-h-0 flex-1 rounded-md'
{table.getHeaderGroups().map((headerGroup) => ( tableContainerClassName='h-full'
<tr key={headerGroup.id} className='border-b'> tableClassName='min-w-[852px] table-fixed'
{headerGroup.headers.map((header) => ( tableHeaderClassName='[&_tr]:border-b-0'
<th splitHeaderScrollClassName='h-full'
key={header.id} bodyContainerClassName='[scrollbar-gutter:stable]'
colSpan={header.colSpan} splitHeader
className={cn( pinnedColumns={[
'bg-background text-foreground sticky top-0 z-10 h-10 px-2 text-left align-middle text-sm font-medium whitespace-nowrap', {
header.column.id === 'actions' && columnId: 'actions',
'right-0 z-30 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]' side: 'right',
)} className: 'w-24 min-w-24',
> },
{header.isPlaceholder ]}
? null colgroup={
: flexRender( <colgroup>
header.column.columnDef.header, <col className='w-9' />
header.getContext() <col className='w-[300px]' />
)} <col className='w-[120px]' />
</th> <col className='w-[300px]' />
))} <col className='w-24' />
</tr> </colgroup>
))} }
</thead> renderRow={(row, { getCellClassName }) => (
<tbody> <DataTableRow
{table.getRowModel().rows.map((row) => ( key={row.id}
<tr row={row}
key={row.id} className={
data-state={row.getIsSelected() ? 'selected' : undefined} editData?.name === row.original.name
className={ ? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted group'
editData?.name === row.original.name : 'group'
? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors' }
: 'hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors' getColumnClassName={(columnId) =>
} columnId === 'actions' &&
onClick={(event) => { editData?.name === row.original.name
const target = event.target as HTMLElement ? getCellClassName(columnId, 'bg-muted')
if (target.closest('button, [role="checkbox"]')) return : getCellClassName(columnId)
handleEdit(row.original) }
}} onClick={(event) => {
> const target = event.target as HTMLElement
{row.getVisibleCells().map((cell) => ( if (target.closest('button, [role="checkbox"]')) return
<td handleEdit(row.original)
key={cell.id} }}
className={cn( />
'p-2 align-middle text-sm whitespace-nowrap', )}
cell.column.id === 'actions' && />
(editData?.name === row.original.name
? 'bg-muted/45 group-hover:bg-muted/50 group-data-[state=selected]:bg-muted sticky right-0 z-10 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]'
: 'bg-background group-hover:bg-muted/50 group-data-[state=selected]:bg-muted sticky right-0 z-10 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]')
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)} )}
{table.getRowModel().rows.length > 0 && ( {hasRows && <DataTablePagination table={table} />}
<DataTablePagination table={table} />
)}
</div> </div>
<div className='hidden min-h-0 min-w-0 md:block'> <div className='hidden min-h-0 min-w-0 md:block'>
@@ -464,7 +464,7 @@ function ConditionRow({ condition, onChange, onRemove }: ConditionRowProps) {
</SelectContent> </SelectContent>
</Select> </Select>
<Select <Select
items={[...OPS.map((op) => ({ value: op, label: op }))]} items={OPS.map((op) => ({ value: op, label: op }))}
value={condition.op} value={condition.op}
onValueChange={(value) => onValueChange={(value) =>
onChange({ ...condition, op: value as TierConditionInput['op'] }) onChange({ ...condition, op: value as TierConditionInput['op'] })
@@ -23,15 +23,8 @@ import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { StaticDataTable } from '@/components/data-table'
import { useUpdateOption } from '../hooks/use-update-option' import { useUpdateOption } from '../hooks/use-update-option'
const OPTION_KEY = 'tool_price_setting.prices' const OPTION_KEY = 'tool_price_setting.prices'
@@ -109,7 +102,6 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
useEffect(() => { useEffect(() => {
const prices = parseInitialPrices(defaultValue) const prices = parseInitialPrices(defaultValue)
const initialRows = objectToRows(prices) const initialRows = objectToRows(prices)
// eslint-disable-next-line react-hooks/set-state-in-effect
setRows(initialRows) setRows(initialRows)
setJsonText(JSON.stringify(prices, null, 2)) setJsonText(JSON.stringify(prices, null, 2))
setJsonError('') setJsonError('')
@@ -261,72 +253,57 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
</div> </div>
{editMode === 'visual' ? ( {editMode === 'visual' ? (
<div className='overflow-hidden rounded-md border'> <StaticDataTable
<Table> data={rows}
<TableHeader> getRowKey={(row) => row.id}
<TableRow> emptyClassName='text-muted-foreground py-8'
<TableHead>{t('Tool identifier')}</TableHead> emptyContent={t('No tools configured')}
<TableHead className='w-[200px]'> columns={[
{t('Price ($/1K calls)')} {
</TableHead> id: 'tool',
<TableHead className='w-[80px] text-right'> header: t('Tool identifier'),
{t('Actions')} cell: (row) => (
</TableHead> <Input
</TableRow> value={row.key}
</TableHeader> placeholder='web_search_preview:gpt-4o*'
<TableBody> onChange={(e) => updateRow(row.id, 'key', e.target.value)}
{rows.length === 0 ? ( />
<TableRow> ),
<TableCell },
colSpan={3} {
className='text-muted-foreground py-8 text-center' id: 'price',
> header: t('Price ($/1K calls)'),
{t('No tools configured')} className: 'w-[200px]',
</TableCell> cell: (row) => (
</TableRow> <Input
) : ( type='number'
rows.map((row) => ( min={0}
<TableRow key={row.id}> step={0.5}
<TableCell> value={row.price}
<Input onChange={(e) =>
value={row.key} updateRow(row.id, 'price', Number(e.target.value) || 0)
placeholder='web_search_preview:gpt-4o*' }
onChange={(e) => />
updateRow(row.id, 'key', e.target.value) ),
} },
/> {
</TableCell> id: 'actions',
<TableCell> header: t('Actions'),
<Input className: 'w-[80px] text-right',
type='number' cellClassName: 'text-right',
min={0} cell: (row) => (
step={0.5} <Button
value={row.price} variant='ghost'
onChange={(e) => size='icon'
updateRow( onClick={() => removeRow(row.id)}
row.id, aria-label={t('Delete')}
'price', >
Number(e.target.value) || 0 <Trash2 className='text-destructive h-4 w-4' />
) </Button>
} ),
/> },
</TableCell> ]}
<TableCell className='text-right'> />
<Button
variant='ghost'
size='icon'
onClick={() => removeRow(row.id)}
aria-label={t('Delete')}
>
<Trash2 className='text-destructive h-4 w-4' />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : ( ) : (
<div className='space-y-2'> <div className='space-y-2'>
<Textarea <Textarea
@@ -17,12 +17,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import {
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table'
import { Loader2, Search } from 'lucide-react' import { Loader2, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -35,14 +29,10 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { import {
Table, DataTablePagination,
TableBody, DataTableView,
TableCell, useDataTable,
TableHead, } from '@/components/data-table'
TableHeader,
TableRow,
} from '@/components/ui/table'
import { DataTablePagination } from '@/components/data-table/pagination'
import type { DifferencesMap, RatioType } from '../types' import type { DifferencesMap, RatioType } from '../types'
import { RATIO_TYPE_OPTIONS } from './constants' import { RATIO_TYPE_OPTIONS } from './constants'
import { useUpstreamRatioSyncColumns } from './upstream-ratio-sync-columns' import { useUpstreamRatioSyncColumns } from './upstream-ratio-sync-columns'
@@ -180,15 +170,14 @@ export function UpstreamRatioSyncTable({
handleBulkUnselect handleBulkUnselect
) )
const table = useReactTable({ const { table } = useDataTable({
data: filteredData, data: filteredData,
columns, columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.key, getRowId: (row) => row.key,
initialState: { initialPagination: { pageIndex: 0, pageSize: 10 },
pagination: { pageSize: 10 }, withFilteredRowModel: false,
}, withSortedRowModel: false,
withFacetedRowModel: false,
}) })
if (dataSource.length === 0) { if (dataSource.length === 0) {
@@ -258,53 +247,15 @@ export function UpstreamRatioSyncTable({
</Select> </Select>
</div> </div>
<div className='overflow-hidden rounded-md border'> <DataTableView
<div className='overflow-x-auto'> table={table}
<Table> containerClassName='rounded-md'
<TableHeader> tableContainerClassName='overflow-x-auto'
{table.getHeaderGroups().map((headerGroup) => ( getColumnClassName={() => 'align-top'}
<TableRow key={headerGroup.id}> getRowClassName={() => 'align-top'}
{headerGroup.headers.map((header) => ( emptyContent={t('No results found')}
<TableHead key={header.id} className='align-top'> emptyCellClassName='h-24 text-center'
{header.isPlaceholder />
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className='align-top'>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className='align-top'>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
{t('No results found')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTablePagination table={table} /> <DataTablePagination table={table} />
</div> </div>
@@ -21,14 +21,7 @@ import { Pencil, Plus, Search, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { import { StaticDataTable } from '@/components/data-table'
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { safeJsonParseWithValidation } from '../utils/json-parser' import { safeJsonParseWithValidation } from '../utils/json-parser'
import { isObjectRecord } from '../utils/json-validators' import { isObjectRecord } from '../utils/json-validators'
import { RateLimitDialog, type RateLimitEntryData } from './rate-limit-dialog' import { RateLimitDialog, type RateLimitEntryData } from './rate-limit-dialog'
@@ -142,69 +135,73 @@ export function RateLimitVisualEditor({
</Button> </Button>
</div> </div>
{filteredRateLimits.length === 0 ? ( <StaticDataTable
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'> data={filteredRateLimits}
{searchText getRowKey={(limit) => limit.groupName}
emptyContent={
searchText
? t('No groups match your search') ? t('No groups match your search')
: t( : t(
'No group-based rate limits configured. Click "Add group" to get started.' 'No group-based rate limits configured. Click "Add group" to get started.'
)} )
</div> }
) : ( columns={[
<div className='rounded-md border'> {
<Table> id: 'group',
<TableHeader> header: t('Group Name'),
<TableRow> cellClassName: 'font-medium',
<TableHead>{t('Group Name')}</TableHead> cell: (limit) => limit.groupName,
<TableHead className='text-right'> },
{t('Max Requests (incl. failures)')} {
</TableHead> id: 'max-requests',
<TableHead className='text-right'>{t('Max Success')}</TableHead> header: t('Max Requests (incl. failures)'),
<TableHead className='text-right'>{t('Actions')}</TableHead> className: 'text-right',
</TableRow> cellClassName: 'text-right',
</TableHeader> cell: (limit) => (
<TableBody> <span className='font-mono'>
{filteredRateLimits.map((limit) => ( {limit.maxRequests === 0
<TableRow key={limit.groupName}> ? t('Unlimited')
<TableCell className='font-medium'> : limit.maxRequests.toLocaleString()}
{limit.groupName} </span>
</TableCell> ),
<TableCell className='text-right'> },
<span className='font-mono'> {
{limit.maxRequests === 0 id: 'max-success',
? t('Unlimited') header: t('Max Success'),
: limit.maxRequests.toLocaleString()} className: 'text-right',
</span> cellClassName: 'text-right',
</TableCell> cell: (limit) => (
<TableCell className='text-right'> <span className='font-mono'>
<span className='font-mono'> {limit.maxSuccess.toLocaleString()}
{limit.maxSuccess.toLocaleString()} </span>
</span> ),
</TableCell> },
<TableCell className='text-right'> {
<div className='flex justify-end gap-2'> id: 'actions',
<Button header: t('Actions'),
variant='ghost' className: 'text-right',
size='sm' cellClassName: 'text-right',
onClick={() => handleEdit(limit)} cell: (limit) => (
> <div className='flex justify-end gap-2'>
<Pencil className='h-4 w-4' /> <Button
</Button> variant='ghost'
<Button size='sm'
variant='ghost' onClick={() => handleEdit(limit)}
size='sm' >
onClick={() => handleDelete(limit.groupName)} <Pencil className='h-4 w-4' />
> </Button>
<Trash2 className='h-4 w-4' /> <Button
</Button> variant='ghost'
</div> size='sm'
</TableCell> onClick={() => handleDelete(limit.groupName)}
</TableRow> >
))} <Trash2 className='h-4 w-4' />
</TableBody> </Button>
</Table> </div>
</div> ),
)} },
]}
/>
<RateLimitDialog <RateLimitDialog
open={dialogOpen} open={dialogOpen}
@@ -274,8 +274,8 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
const config = getLogTypeConfig(log.type) const config = getLogTypeConfig(log.type)
return ( return (
<div className='flex flex-col gap-0.5'> <div className='flex min-w-0 flex-col gap-0.5'>
<span className='font-mono text-xs tabular-nums'> <span className='truncate font-mono text-xs tabular-nums'>
{formatTimestampToDate(timestamp)} {formatTimestampToDate(timestamp)}
</span> </span>
<StatusBadge <StatusBadge
@@ -294,6 +294,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
return value.includes(String(row.original.type)) return value.includes(String(row.original.type))
}, },
enableHiding: false, enableHiding: false,
size: 180,
meta: { label: t('Time') }, meta: { label: t('Time') },
}, },
] ]
@@ -752,7 +753,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
return ( return (
<div className='flex flex-col gap-0.5'> <div className='flex flex-col gap-0.5'>
<span className='border-border/80 bg-muted/60 inline-flex h-6 w-fit items-center rounded-md border px-2 text-sm leading-none [font-family:var(--font-body)] font-semibold tabular-nums'> <span className='border-border/80 bg-muted/60 inline-flex h-6 w-fit items-center rounded-md border px-2 [font-family:var(--font-body)] text-sm leading-none font-semibold tabular-nums'>
{quotaDisplay.prefix && ( {quotaDisplay.prefix && (
<span className='mr-1'>{quotaDisplay.prefix}</span> <span className='mr-1'>{quotaDisplay.prefix}</span>
)} )}
@@ -95,8 +95,8 @@ export function useDrawingLogsColumns(
const submitTime = row.getValue('submit_time') as number const submitTime = row.getValue('submit_time') as number
return ( return (
<div className='flex flex-col gap-0.5'> <div className='flex min-w-0 flex-col gap-0.5'>
<span className='font-mono text-xs tabular-nums'> <span className='truncate font-mono text-xs tabular-nums'>
{formatTimestampToDate(submitTime)} {formatTimestampToDate(submitTime)}
</span> </span>
<StatusBadge <StatusBadge
@@ -108,6 +108,7 @@ export function useDrawingLogsColumns(
</div> </div>
) )
}, },
size: 180,
meta: { label: t('Submit Time') }, meta: { label: t('Submit Time') },
}, },
] ]
@@ -102,12 +102,12 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
const submitTime = row.getValue('submit_time') as number const submitTime = row.getValue('submit_time') as number
return ( return (
<div className='flex flex-col gap-0.5'> <div className='flex min-w-0 flex-col gap-0.5'>
<span className='font-mono text-xs tabular-nums'> <span className='truncate font-mono text-xs tabular-nums'>
{formatTimestampToDate(submitTime, 'seconds')} {formatTimestampToDate(submitTime, 'seconds')}
</span> </span>
{log.finish_time ? ( {log.finish_time ? (
<span className='text-muted-foreground/60 font-mono text-[11px] tabular-nums'> <span className='text-muted-foreground/60 truncate font-mono text-[11px] tabular-nums'>
{formatTimestampToDate(log.finish_time, 'seconds')} {formatTimestampToDate(log.finish_time, 'seconds')}
</span> </span>
) : ( ) : (
@@ -116,6 +116,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
</div> </div>
) )
}, },
size: 180,
meta: { label: t('Submit Time') }, meta: { label: t('Submit Time') },
}, },
] ]
@@ -16,11 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { import { flexRender, type Cell, type Table } from '@tanstack/react-table'
flexRender,
type Cell,
type Table,
} from '@tanstack/react-table'
import { Database } from 'lucide-react' import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { formatTimestampToDate } from '@/lib/format' import { formatTimestampToDate } from '@/lib/format'
@@ -33,14 +29,20 @@ import {
EmptyTitle, EmptyTitle,
} from '@/components/ui/empty' } from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { dotColorMap, textColorMap, type StatusVariant } from '@/components/status-badge' import {
import type { LogCategory } from '../types' dotColorMap,
textColorMap,
type StatusVariant,
} from '@/components/status-badge'
import { LOG_TYPE_ENUM } from '../constants' import { LOG_TYPE_ENUM } from '../constants'
import { getLogTypeConfig } from '../lib/utils' import { getLogTypeConfig } from '../lib/utils'
import type { LogCategory } from '../types'
const logTypeRowTint: Record<number, string> = { const logTypeRowTint: Record<number, string> = {
[LOG_TYPE_ENUM.ERROR]: 'bg-rose-50/40 dark:bg-rose-950/20 border-rose-200/50 dark:border-rose-900/30', [LOG_TYPE_ENUM.ERROR]:
[LOG_TYPE_ENUM.REFUND]: 'bg-blue-50/30 dark:bg-blue-950/15 border-blue-200/50 dark:border-blue-900/30', 'bg-rose-50/40 dark:bg-rose-950/20 border-rose-200/50 dark:border-rose-900/30',
[LOG_TYPE_ENUM.REFUND]:
'bg-blue-50/30 dark:bg-blue-950/15 border-blue-200/50 dark:border-blue-900/30',
} }
interface UsageLogsMobileListProps<TData> { interface UsageLogsMobileListProps<TData> {
@@ -53,11 +55,11 @@ interface UsageLogsMobileListProps<TData> {
function UsageLogsMobileSkeleton() { function UsageLogsMobileSkeleton() {
return ( return (
<div className='overflow-hidden rounded-lg border border-border/50 bg-card'> <div className='border-border/50 bg-card overflow-hidden rounded-lg border'>
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div <div
key={i} key={i}
className='space-y-2.5 border-b border-border/40 p-3 last:border-b-0' className='border-border/40 space-y-2.5 border-b p-3 last:border-b-0'
> >
<div className='flex items-center justify-between gap-3'> <div className='flex items-center justify-between gap-3'>
<Skeleton className='h-5 w-40 rounded-md' /> <Skeleton className='h-5 w-40 rounded-md' />
@@ -93,7 +95,7 @@ function CompactCell<TData>({
className={cn( className={cn(
'min-w-0 overflow-hidden leading-tight [&_button]:max-w-full [&_span]:max-w-full', 'min-w-0 overflow-hidden leading-tight [&_button]:max-w-full [&_span]:max-w-full',
primaryOnly && primaryOnly &&
'[&_.flex-col>*:not(:first-child)]:hidden [&_.flex-col]:min-w-0', '[&_.flex-col]:min-w-0 [&_.flex-col>*:not(:first-child)]:hidden',
className className
)} )}
> >
@@ -123,10 +125,7 @@ function SummaryField<TData>({
return ( return (
<div <div
className={cn( className={cn('bg-muted/20 min-w-0 rounded-md px-2 py-1.5', className)}
'min-w-0 rounded-md bg-muted/20 px-2 py-1.5',
className
)}
> >
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'> <div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
{label} {label}
@@ -198,7 +197,7 @@ function CommonLogsCard<TData>({
</div> </div>
<div className='grid grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)] gap-1.5'> <div className='grid grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)] gap-1.5'>
<div className='min-w-0 rounded-md bg-muted/20 px-2 py-1.5'> <div className='bg-muted/20 min-w-0 rounded-md px-2 py-1.5'>
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'> <div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
{t('Time')} {t('Time')}
</div> </div>
@@ -257,15 +256,8 @@ function TaskLogsCard<TData>({
</div> </div>
<div className='grid grid-cols-2 gap-1.5'> <div className='grid grid-cols-2 gap-1.5'>
<SummaryField <SummaryField label={t('Submit Time')} cell={submitTimeCell} />
label={t('Submit Time')} <SummaryField label={t('User')} cell={cells.get('user')} primaryOnly />
cell={submitTimeCell}
/>
<SummaryField
label={t('User')}
cell={cells.get('user')}
primaryOnly
/>
<SummaryField <SummaryField
label={t('Result')} label={t('Result')}
cell={cells.get('fail_reason')} cell={cells.get('fail_reason')}
@@ -295,28 +287,19 @@ function DrawingLogsCard<TData>({
</div> </div>
<div className='grid grid-cols-2 gap-1.5'> <div className='grid grid-cols-2 gap-1.5'>
<SummaryField <SummaryField label={t('Submit Time')} cell={submitTimeCell} />
label={t('Submit Time')}
cell={submitTimeCell}
/>
<SummaryField <SummaryField
label={t('Channel')} label={t('Channel')}
cell={cells.get('channel')} cell={cells.get('channel')}
primaryOnly primaryOnly
/> />
<SummaryField <SummaryField label={t('Task ID')} cell={cells.get('mj_id')} />
label={t('Task ID')}
cell={cells.get('mj_id')}
/>
<SummaryField <SummaryField
label={t('Duration')} label={t('Duration')}
cell={cells.get('duration')} cell={cells.get('duration')}
primaryOnly primaryOnly
/> />
<SummaryField <SummaryField label={t('Image')} cell={cells.get('image_url')} />
label={t('Image')}
cell={cells.get('image_url')}
/>
<SummaryField <SummaryField
label={t('Prompt')} label={t('Prompt')}
cell={cells.get('prompt')} cell={cells.get('prompt')}
@@ -354,11 +337,11 @@ export function UsageLogsMobileList<TData>({
if (!rows || rows.length === 0) { if (!rows || rows.length === 0) {
return ( return (
<div className="rounded-lg border p-6"> <div className='rounded-lg border p-6'>
<Empty className="border-none p-0"> <Empty className='border-none p-0'>
<EmptyHeader> <EmptyHeader>
<EmptyMedia variant="icon"> <EmptyMedia variant='icon'>
<Database className="size-6" /> <Database className='size-6' />
</EmptyMedia> </EmptyMedia>
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle> <EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription> <EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
@@ -369,7 +352,7 @@ export function UsageLogsMobileList<TData>({
} }
return ( return (
<div className='overflow-hidden rounded-lg border border-border/50 bg-card'> <div className='border-border/50 bg-card overflow-hidden rounded-lg border'>
{rows.map((row) => { {rows.map((row) => {
const cells = new Map( const cells = new Map(
row.getVisibleCells().map((cell) => [cell.column.id, cell]) row.getVisibleCells().map((cell) => [cell.column.id, cell])
@@ -384,19 +367,13 @@ export function UsageLogsMobileList<TData>({
<div <div
key={row.id} key={row.id}
className={cn( className={cn(
'border-l-2 border-l-transparent border-b border-border/40 p-3 transition-colors last:border-b-0', 'border-border/40 border-b border-l-2 border-l-transparent p-3 transition-colors last:border-b-0',
tintClass tintClass
)} )}
> >
{logCategory === 'common' && ( {logCategory === 'common' && <CommonLogsCard cells={cells} />}
<CommonLogsCard cells={cells} /> {logCategory === 'task' && <TaskLogsCard cells={cells} />}
)} {logCategory === 'drawing' && <DrawingLogsCard cells={cells} />}
{logCategory === 'task' && (
<TaskLogsCard cells={cells} />
)}
{logCategory === 'drawing' && (
<DrawingLogsCard cells={cells} />
)}
</div> </div>
) )
})} })}
@@ -16,27 +16,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useEffect } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router' import { getRouteApi } from '@tanstack/react-router'
import { import { type ColumnDef } from '@tanstack/react-table'
type ColumnDef,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks' import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useIsAdmin } from '@/hooks/use-admin' import { useIsAdmin } from '@/hooks/use-admin'
import { useTableUrlState } from '@/hooks/use-table-url-state' import { useTableUrlState } from '@/hooks/use-table-url-state'
import { TableCell, TableRow } from '@/components/ui/table' import {
import { DataTablePage } from '@/components/data-table' DataTablePage,
DataTableRow,
useDataTable,
} from '@/components/data-table'
import { import {
DEFAULT_LOGS_DATA, DEFAULT_LOGS_DATA,
LOG_TYPE_ALL_VALUE, LOG_TYPE_ALL_VALUE,
@@ -149,31 +142,20 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
const columns = useColumnsByCategory(logCategory, isAdmin) const columns = useColumnsByCategory(logCategory, isAdmin)
const isLoadingData = isLoading || (isFetching && !data) const isLoadingData = isLoading || (isFetching && !data)
const table = useReactTable({ const { table } = useDataTable({
data: logs as Record<string, unknown>[], data: logs as Record<string, unknown>[],
columns: columns as ColumnDef<Record<string, unknown>>[], columns: columns as ColumnDef<Record<string, unknown>>[],
state: { columnFilters,
columnFilters, pagination,
pagination,
},
enableRowSelection: false, enableRowSelection: false,
onPaginationChange, onPaginationChange,
onColumnFiltersChange, onColumnFiltersChange,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
manualPagination: true, manualPagination: true,
manualFiltering: true, manualFiltering: true,
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize), totalCount: data?.total || 0,
ensurePageInRange,
}) })
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [pageCount, ensurePageInRange])
const isCommon = logCategory === 'common' const isCommon = logCategory === 'common'
return ( return (
@@ -187,11 +169,10 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
'No usage logs available. Logs will appear here once API calls are made.' 'No usage logs available. Logs will appear here once API calls are made.'
)} )}
skeletonKeyPrefix='usage-log-skeleton' skeletonKeyPrefix='usage-log-skeleton'
applyHeaderSize
tableClassName={cn( tableClassName={cn(
'overflow-x-auto',
'[&_[data-slot=table]]:text-[13px] [&_[data-slot=table]_td]:text-[13px] [&_[data-slot=table]_td_*]:text-[13px] [&_[data-slot=table]_th]:text-[13px] [&_[data-slot=table]_th_*]:text-[13px]' '[&_[data-slot=table]]:text-[13px] [&_[data-slot=table]_td]:text-[13px] [&_[data-slot=table]_td_*]:text-[13px] [&_[data-slot=table]_th]:text-[13px] [&_[data-slot=table]_th_*]:text-[13px]'
)} )}
tableHeaderClassName='bg-muted/30 sticky top-0 z-10'
mobile={ mobile={
<UsageLogsMobileList <UsageLogsMobileList
table={table} table={table}
@@ -214,13 +195,12 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
isCommon && logType != null ? (logTypeRowTint[logType] ?? '') : '' isCommon && logType != null ? (logTypeRowTint[logType] ?? '') : ''
return ( return (
<TableRow key={row.id} className={cn('transition-colors', tintClass)}> <DataTableRow
{row.getVisibleCells().map((cell) => ( key={row.id}
<TableCell key={cell.id} className={isCommon ? 'py-2' : 'py-3.5'}> row={row}
{flexRender(cell.column.columnDef.cell, cell.getContext())} className={cn('transition-colors', tintClass)}
</TableCell> getColumnClassName={() => (isCommon ? 'py-2' : 'py-3.5')}
))} />
</TableRow>
) )
}} }}
/> />
+5 -3
View File
@@ -110,12 +110,12 @@ function UsageLogsContent() {
return ( return (
<> <>
<SectionPageLayout> <SectionPageLayout fixedContent>
<SectionPageLayout.Title> <SectionPageLayout.Title>
{t(pageMeta.titleKey)} {t(pageMeta.titleKey)}
</SectionPageLayout.Title> </SectionPageLayout.Title>
<SectionPageLayout.Content> <SectionPageLayout.Content>
<div className='space-y-4'> <div className='flex h-full min-h-0 flex-col gap-4'>
{showTaskSwitcher && ( {showTaskSwitcher && (
<Tabs value={activeCategory} onValueChange={handleSectionChange}> <Tabs value={activeCategory} onValueChange={handleSectionChange}>
<TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'> <TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'>
@@ -127,7 +127,9 @@ function UsageLogsContent() {
</TabsList> </TabsList>
</Tabs> </Tabs>
)} )}
<UsageLogsTable logCategory={activeCategory} /> <div className='min-h-0 flex-1'>
<UsageLogsTable logCategory={activeCategory} />
</div>
</div> </div>
</SectionPageLayout.Content> </SectionPageLayout.Content>
</SectionPageLayout> </SectionPageLayout>
+11 -1
View File
@@ -66,6 +66,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
), ),
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
size: 40,
meta: { label: t('Select') }, meta: { label: t('Select') },
}, },
{ {
@@ -78,6 +79,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
<TableId value={row.getValue('id') as number} className='w-[60px]' /> <TableId value={row.getValue('id') as number} className='w-[60px]' />
) )
}, },
size: 80,
meta: { label: t('ID'), mobileHidden: true }, meta: { label: t('ID'), mobileHidden: true },
}, },
{ {
@@ -118,6 +120,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
) )
}, },
enableHiding: false, enableHiding: false,
size: 220,
meta: { label: t('Username'), mobileTitle: true }, meta: { label: t('Username'), mobileTitle: true },
}, },
{ {
@@ -158,6 +161,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
return value.includes(String(row.getValue(id))) return value.includes(String(row.getValue(id)))
}, },
enableSorting: false, enableSorting: false,
size: 120,
meta: { label: t('Status'), mobileBadge: true }, meta: { label: t('Status'), mobileBadge: true },
}, },
{ {
@@ -220,6 +224,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
</Tooltip> </Tooltip>
) )
}, },
size: 170,
meta: { label: t('Quota') }, meta: { label: t('Quota') },
}, },
{ {
@@ -236,6 +241,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
const searchValue = String(value).toLowerCase() const searchValue = String(value).toLowerCase()
return group.includes(searchValue) return group.includes(searchValue)
}, },
size: 140,
meta: { label: t('Group') }, meta: { label: t('Group') },
}, },
{ {
@@ -264,6 +270,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
return value.includes(String(row.getValue(id))) return value.includes(String(row.getValue(id)))
}, },
enableSorting: false, enableSorting: false,
size: 120,
meta: { label: t('Role') }, meta: { label: t('Role') },
}, },
{ {
@@ -278,7 +285,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
const inviterId = user.inviter_id || 0 const inviterId = user.inviter_id || 0
return ( return (
<div className='flex items-center gap-1'> <div className='flex min-w-[220px] flex-wrap items-center gap-1'>
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
@@ -338,6 +345,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
</div> </div>
) )
}, },
size: 240,
enableSorting: false, enableSorting: false,
meta: { label: t('Invite Info'), mobileHidden: true }, meta: { label: t('Invite Info'), mobileHidden: true },
}, },
@@ -354,6 +362,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
</span> </span>
) )
}, },
size: 180,
meta: { label: t('Created At'), mobileHidden: true }, meta: { label: t('Created At'), mobileHidden: true },
}, },
{ {
@@ -369,6 +378,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
</span> </span>
) )
}, },
size: 180,
meta: { label: t('Last Login'), mobileHidden: true }, meta: { label: t('Last Login'), mobileHidden: true },
}, },
{ {
+8 -39
View File
@@ -16,20 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router' import { getRouteApi } from '@tanstack/react-router'
import {
type SortingState,
type VisibilityState,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks' import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -38,6 +26,7 @@ import {
DISABLED_ROW_DESKTOP, DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE, DISABLED_ROW_MOBILE,
DataTablePage, DataTablePage,
useDataTable,
} from '@/components/data-table' } from '@/components/data-table'
import { getUsers, searchUsers } from '../api' import { getUsers, searchUsers } from '../api'
import { import {
@@ -62,9 +51,6 @@ export function UsersTable() {
const columns = useUsersColumns() const columns = useUsersColumns()
const { refreshTrigger } = useUsers() const { refreshTrigger } = useUsers()
const isMobile = useMediaQuery('(max-width: 640px)') const isMobile = useMediaQuery('(max-width: 640px)')
const [rowSelection, setRowSelection] = useState({})
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const { const {
globalFilter, globalFilter,
@@ -146,21 +132,13 @@ export function UsersTable() {
const users = data?.items || [] const users = data?.items || []
const table = useReactTable({ const { table } = useDataTable({
data: users, data: users,
columns, columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
globalFilter,
pagination,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, columnFilters,
onSortingChange: setSorting, globalFilter,
onColumnVisibilityChange: setColumnVisibility, pagination,
globalFilterFn: (row, _columnId, filterValue) => { globalFilterFn: (row, _columnId, filterValue) => {
const searchValue = String(filterValue).toLowerCase() const searchValue = String(filterValue).toLowerCase()
const fields = [ const fields = [
@@ -174,24 +152,14 @@ export function UsersTable() {
.includes(searchValue) .includes(searchValue)
) )
}, },
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
onPaginationChange, onPaginationChange,
onGlobalFilterChange, onGlobalFilterChange,
onColumnFiltersChange, onColumnFiltersChange,
manualPagination: true, manualPagination: true,
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize), totalCount: data?.total || 0,
ensurePageInRange,
}) })
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [pageCount, ensurePageInRange])
return ( return (
<DataTablePage <DataTablePage
table={table} table={table}
@@ -203,6 +171,7 @@ export function UsersTable() {
'No users available. Try adjusting your search or filters.' 'No users available. Try adjusting your search or filters.'
)} )}
skeletonKeyPrefix='users-skeleton' skeletonKeyPrefix='users-skeleton'
applyHeaderSize
toolbarProps={{ toolbarProps={{
searchPlaceholder: t('Filter by username, name or email...'), searchPlaceholder: t('Filter by username, name or email...'),
filters: [ filters: [
+1 -1
View File
@@ -30,7 +30,7 @@ function UsersContent() {
return ( return (
<> <>
<SectionPageLayout> <SectionPageLayout fixedContent>
<SectionPageLayout.Title>{t('Users')}</SectionPageLayout.Title> <SectionPageLayout.Title>{t('Users')}</SectionPageLayout.Title>
<SectionPageLayout.Actions> <SectionPageLayout.Actions>
<UsersPrimaryButtons /> <UsersPrimaryButtons />
+2
View File
@@ -1856,6 +1856,7 @@
"Go to io.net API Keys": "Go to io.net API Keys", "Go to io.net API Keys": "Go to io.net API Keys",
"Go to last page": "Go to last page", "Go to last page": "Go to last page",
"Go to next page": "Go to next page", "Go to next page": "Go to next page",
"Go to page {{page}}": "Go to page {{page}}",
"Go to previous page": "Go to previous page", "Go to previous page": "Go to previous page",
"Go to settings": "Go to settings", "Go to settings": "Go to settings",
"Go to Settings": "Go to Settings", "Go to Settings": "Go to Settings",
@@ -3208,6 +3209,7 @@
"Redeem codes": "Redeem codes", "Redeem codes": "Redeem codes",
"Redeemed By": "Redeemed By", "Redeemed By": "Redeemed By",
"Redeemed:": "Redeemed:", "Redeemed:": "Redeemed:",
"Received amount": "Received amount",
"redemption code": "redemption code", "redemption code": "redemption code",
"Redemption Code": "Redemption Code", "Redemption Code": "Redemption Code",
"Redemption code deleted successfully": "Redemption code deleted successfully", "Redemption code deleted successfully": "Redemption code deleted successfully",
+2
View File
@@ -1856,6 +1856,7 @@
"Go to io.net API Keys": "Accéder aux clés API io.net", "Go to io.net API Keys": "Accéder aux clés API io.net",
"Go to last page": "Aller à la dernière page", "Go to last page": "Aller à la dernière page",
"Go to next page": "Aller à la page suivante", "Go to next page": "Aller à la page suivante",
"Go to page {{page}}": "Aller à la page {{page}}",
"Go to previous page": "Aller à la page précédente", "Go to previous page": "Aller à la page précédente",
"Go to settings": "Aller aux paramètres", "Go to settings": "Aller aux paramètres",
"Go to Settings": "Aller aux paramètres", "Go to Settings": "Aller aux paramètres",
@@ -3208,6 +3209,7 @@
"Redeem codes": "Échanger des codes", "Redeem codes": "Échanger des codes",
"Redeemed By": "Utilisé par", "Redeemed By": "Utilisé par",
"Redeemed:": "Utilisé :", "Redeemed:": "Utilisé :",
"Received amount": "Montant reçu",
"redemption code": "code d'échange", "redemption code": "code d'échange",
"Redemption Code": "Code d'échange", "Redemption Code": "Code d'échange",
"Redemption code deleted successfully": "Code d'échange supprimé avec succès", "Redemption code deleted successfully": "Code d'échange supprimé avec succès",
+2
View File
@@ -1856,6 +1856,7 @@
"Go to io.net API Keys": "io.net API キーへ移動", "Go to io.net API Keys": "io.net API キーへ移動",
"Go to last page": "最後のページへ移動", "Go to last page": "最後のページへ移動",
"Go to next page": "次のページへ移動", "Go to next page": "次のページへ移動",
"Go to page {{page}}": "{{page}}ページ目へ移動",
"Go to previous page": "前のページへ移動", "Go to previous page": "前のページへ移動",
"Go to settings": "設定へ", "Go to settings": "設定へ",
"Go to Settings": "設定へ移動", "Go to Settings": "設定へ移動",
@@ -3208,6 +3209,7 @@
"Redeem codes": "コードを交換", "Redeem codes": "コードを交換",
"Redeemed By": "引き換え元", "Redeemed By": "引き換え元",
"Redeemed:": "引き換え済み:", "Redeemed:": "引き換え済み:",
"Received amount": "受け取り額",
"redemption code": "引き換えコード", "redemption code": "引き換えコード",
"Redemption Code": "引き換えコード", "Redemption Code": "引き換えコード",
"Redemption code deleted successfully": "引き換えコードを正常に削除しました", "Redemption code deleted successfully": "引き換えコードを正常に削除しました",
+2
View File
@@ -1856,6 +1856,7 @@
"Go to io.net API Keys": "Перейти к ключам API io.net", "Go to io.net API Keys": "Перейти к ключам API io.net",
"Go to last page": "Перейти на последнюю страницу", "Go to last page": "Перейти на последнюю страницу",
"Go to next page": "Перейти на следующую страницу", "Go to next page": "Перейти на следующую страницу",
"Go to page {{page}}": "Перейти на страницу {{page}}",
"Go to previous page": "Перейти на предыдущую страницу", "Go to previous page": "Перейти на предыдущую страницу",
"Go to settings": "Перейти к настройкам", "Go to settings": "Перейти к настройкам",
"Go to Settings": "Перейти к настройкам", "Go to Settings": "Перейти к настройкам",
@@ -3208,6 +3209,7 @@
"Redeem codes": "Активировать коды", "Redeem codes": "Активировать коды",
"Redeemed By": "Активировано", "Redeemed By": "Активировано",
"Redeemed:": "Активировано:", "Redeemed:": "Активировано:",
"Received amount": "Полученная сумма",
"redemption code": "код активации", "redemption code": "код активации",
"Redemption Code": "Код активации", "Redemption Code": "Код активации",
"Redemption code deleted successfully": "Код активации успешно удален", "Redemption code deleted successfully": "Код активации успешно удален",
+4 -2
View File
@@ -1851,11 +1851,12 @@
"Go Back": "Quay lại", "Go Back": "Quay lại",
"Go back and edit": "Quay lại và chỉnh sửa", "Go back and edit": "Quay lại và chỉnh sửa",
"Go to Dashboard": "Truy cập Dashboard", "Go to Dashboard": "Truy cập Dashboard",
"Go to first page": "Go to the first page", "Go to first page": "Đi đến trang đầu tiên",
"Go to home": "Về trang chủ", "Go to home": "Về trang chủ",
"Go to io.net API Keys": "Đi đến Khóa API io.net", "Go to io.net API Keys": "Đi đến Khóa API io.net",
"Go to last page": "Go to the last page", "Go to last page": "Đi đến trang cuối cùng",
"Go to next page": "Đi đến trang tiếp theo", "Go to next page": "Đi đến trang tiếp theo",
"Go to page {{page}}": "Đi đến trang {{page}}",
"Go to previous page": "Quay lại trang trước", "Go to previous page": "Quay lại trang trước",
"Go to settings": "Đi tới cài đặt", "Go to settings": "Đi tới cài đặt",
"Go to Settings": "Đi đến Cài đặt", "Go to Settings": "Đi đến Cài đặt",
@@ -3208,6 +3209,7 @@
"Redeem codes": "Đổi mã", "Redeem codes": "Đổi mã",
"Redeemed By": "Được chuộc bởi", "Redeemed By": "Được chuộc bởi",
"Redeemed:": "Đã đổi:", "Redeemed:": "Đã đổi:",
"Received amount": "Số tiền đã nhận",
"redemption code": "mã đổi thưởng", "redemption code": "mã đổi thưởng",
"Redemption Code": "Mã đổi thưởng", "Redemption Code": "Mã đổi thưởng",
"Redemption code deleted successfully": "Mã đổi thưởng đã xóa thành công", "Redemption code deleted successfully": "Mã đổi thưởng đã xóa thành công",
+2
View File
@@ -1856,6 +1856,7 @@
"Go to io.net API Keys": "前往 io.net API 密钥", "Go to io.net API Keys": "前往 io.net API 密钥",
"Go to last page": "前往末页", "Go to last page": "前往末页",
"Go to next page": "前往下一页", "Go to next page": "前往下一页",
"Go to page {{page}}": "前往第 {{page}} 页",
"Go to previous page": "前往上一页", "Go to previous page": "前往上一页",
"Go to settings": "前往设置", "Go to settings": "前往设置",
"Go to Settings": "前往设置", "Go to Settings": "前往设置",
@@ -3208,6 +3209,7 @@
"Redeem codes": "兑换码", "Redeem codes": "兑换码",
"Redeemed By": "兑换人", "Redeemed By": "兑换人",
"Redeemed:": "已兑换:", "Redeemed:": "已兑换:",
"Received amount": "已收额度",
"redemption code": "兑换码", "redemption code": "兑换码",
"Redemption Code": "兑换码", "Redemption Code": "兑换码",
"Redemption code deleted successfully": "兑换码删除成功", "Redemption code deleted successfully": "兑换码删除成功",
+10 -21
View File
@@ -46,42 +46,31 @@ export function sanitizeCssVariableName(name: string): string {
* @returns Array of page numbers and ellipsis strings * @returns Array of page numbers and ellipsis strings
* *
* Examples: * Examples:
* - Small dataset (5 pages): [1, 2, 3, 4, 5] * - Small dataset (4 pages): [1, 2, 3, 4]
* - Near beginning: [1, 2, 3, 4, '...', 10] * - Near beginning: [1, 2, '...', 10]
* - In middle: [1, '...', 4, 5, 6, '...', 10] * - In middle: [1, '...', 5, '...', 10]
* - Near end: [1, '...', 7, 8, 9, 10] * - Near end: [1, '...', 9, 10]
*/ */
export function getPageNumbers(currentPage: number, totalPages: number) { export function getPageNumbers(currentPage: number, totalPages: number) {
const maxVisiblePages = 5 // Maximum number of page buttons to show const maxVisiblePages = 4
const rangeWithDots = [] const rangeWithDots = []
if (totalPages <= maxVisiblePages) { if (totalPages <= maxVisiblePages) {
// If total pages is 5 or less, show all pages
for (let i = 1; i <= totalPages; i++) { for (let i = 1; i <= totalPages; i++) {
rangeWithDots.push(i) rangeWithDots.push(i)
} }
} else { } else {
// Always show first page
rangeWithDots.push(1) rangeWithDots.push(1)
if (currentPage <= 3) { if (currentPage <= 2) {
// Near the beginning: [1] [2] [3] [4] ... [10] rangeWithDots.push(2)
for (let i = 2; i <= 4; i++) {
rangeWithDots.push(i)
}
rangeWithDots.push('...', totalPages) rangeWithDots.push('...', totalPages)
} else if (currentPage >= totalPages - 2) { } else if (currentPage >= totalPages - 1) {
// Near the end: [1] ... [7] [8] [9] [10]
rangeWithDots.push('...') rangeWithDots.push('...')
for (let i = totalPages - 3; i <= totalPages; i++) { rangeWithDots.push(totalPages - 1, totalPages)
rangeWithDots.push(i)
}
} else { } else {
// In the middle: [1] ... [4] [5] [6] ... [10]
rangeWithDots.push('...') rangeWithDots.push('...')
for (let i = currentPage - 1; i <= currentPage + 1; i++) { rangeWithDots.push(currentPage)
rangeWithDots.push(i)
}
rangeWithDots.push('...', totalPages) rangeWithDots.push('...', totalPages)
} }
} }