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,
columnFilters, initialColumnVisibility: {
columnVisibility, models: false,
rowSelection, tag: false,
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}>
{t(option.label)}
</SelectItem> </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'> }}
tableContainerClassName='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'
tableClassName='w-max min-w-full table-auto'
pinnedColumns={[
{
columnId: 'actions',
side: 'right',
className: 'w-24 min-w-24 sm:w-28 sm:min-w-28',
cellClassName: 'bg-popover',
},
]}
colgroup={
<colgroup> <colgroup>
<col className='w-10 min-w-10' /> <col className='w-10 min-w-10' />
<col className='w-auto' /> <col className='w-auto' />
<col className='w-70' /> <col className='w-70' />
<col className='w-24 sm:w-28' /> <col className='w-24 sm:w-28' />
</colgroup> </colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={getTestTableColumnClass(
header.column.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' : undefined
} }
> getColumnClassName={(columnId) =>
{row.getVisibleCells().map((cell) => ( getTestTableColumnClass(columnId)
<TableCell }
key={cell.id} emptyContent={
className={getTestTableColumnClass( models.length
cell.column.id ? t('No models matched your search.')
)} : t('This channel has no configured models.')
> }
{flexRender( emptyCellClassName='text-muted-foreground h-16 text-center text-sm'
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',
cell: (key) => formatKeyTimestamp(key.disabled_time),
},
{
id: 'actions',
header: t('Actions'),
className: 'w-44 text-right',
cell: (key) => (
<MultiKeyTableRowActions <MultiKeyTableRowActions
keyIndex={key.index} keyIndex={key.index}
status={key.status} status={key.status}
onAction={setConfirmAction} onAction={setConfirmAction}
/> />
</TableCell> ),
</TableRow> },
))} ]}
</TableBody> />
</Table>
</div>
)} )}
</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 },
}, },
{ {
+17 -59
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: { enableRowSelection: true,
sorting,
columnVisibility,
rowSelection,
columnFilters, columnFilters,
globalFilter, globalFilter,
pagination, pagination,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
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,
columnVisibility,
pagination, pagination,
globalFilter, 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,25 +337,16 @@ 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'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{normalizedGroups.map(({ group, meta, parsedItems }) => (
<TableRow key={group.id}>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
<div className='flex flex-wrap items-center gap-2'> <div className='flex flex-wrap items-center gap-2'>
<span className='font-medium'>{group.name}</span> <span className='font-medium'>{group.name}</span>
@@ -378,16 +362,28 @@ export function PrefillGroupManagementDialog({
</p> </p>
)} )}
</div> </div>
</TableCell> ),
<TableCell className='align-top'> },
{
id: 'type',
header: t('Type'),
cellClassName: 'align-top',
cell: ({ meta }) => (
<StatusBadge <StatusBadge
label={meta.label} label={meta.label}
variant={meta.badge} variant={meta.badge}
size='sm' size='sm'
copyable={false} copyable={false}
/> />
</TableCell> ),
<TableCell className='align-top whitespace-normal'> },
{
id: 'items',
header: t('Items'),
className: 'min-w-[240px]',
cellClassName: 'align-top whitespace-normal',
cell: ({ group, parsedItems }) => (
<>
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
{parsedItems.length > 0 ? ( {parsedItems.length > 0 ? (
<> <>
@@ -420,8 +416,15 @@ export function PrefillGroupManagementDialog({
{parsedItems.length} item {parsedItems.length} item
{parsedItems.length === 1 ? '' : 's'} {parsedItems.length === 1 ? '' : 's'}
</div> </div>
</TableCell> </>
<TableCell className='align-top'> ),
},
{
id: 'actions',
header: t('Actions'),
className: 'w-[120px] text-right',
cellClassName: 'align-top',
cell: ({ group }) => (
<div className='flex justify-end gap-2'> <div className='flex justify-end gap-2'>
<Button <Button
size='icon' size='icon'
@@ -441,13 +444,10 @@ export function PrefillGroupManagementDialog({
<span className='sr-only'>Delete group</span> <span className='sr-only'>Delete group</span>
</Button> </Button>
</div> </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
+10 -34
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,
bound_channels: false,
quota_types: false,
},
columnFilters, columnFilters,
columnVisibility,
rowSelection,
pagination, pagination,
globalFilter, 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 = [
{ {
+4 -2
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,6 +142,7 @@ function ModelsContent() {
))} ))}
</TabsList> </TabsList>
</Tabs> </Tabs>
<div className='min-h-0 flex-1'>
{activeSection === 'metadata' ? ( {activeSection === 'metadata' ? (
<ModelsTable /> <ModelsTable />
) : ( ) : (
@@ -158,6 +159,7 @@ function ModelsContent() {
</DeploymentAccessGuard> </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,39 +300,37 @@ 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) => (
<TableHead
key={v.field}
className='text-muted-foreground py-2 text-right font-medium'
>
{t(v.shortLabel)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{tiers.map((tier, i) => {
const condSummary = formatConditionSummary(tier.conditions, t)
const isMatched = const isMatched =
normalizedMatchedTierLabel !== '' && normalizedMatchedTierLabel !== '' &&
normalizeTierLabel(tier.label) === normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
normalizedMatchedTierLabel return cn(
return (
<TableRow
key={`tier-${i}`}
className={cn(
isMatched && isMatched &&
'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10' 'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10'
)} )
> }}
<TableCell className='py-2.5 align-top'> columns={[
{
id: 'tier',
header: t('Tier'),
className: 'text-muted-foreground py-2 font-medium',
cellClassName: 'py-2.5 align-top',
cell: (tier) => {
const condSummary = formatConditionSummary(
tier.conditions,
t
)
const isMatched =
normalizedMatchedTierLabel !== '' &&
normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
return (
<>
<div className='flex flex-wrap items-center gap-1.5'> <div className='flex flex-wrap items-center gap-1.5'>
<Badge <Badge
variant='secondary' variant='secondary'
@@ -361,32 +352,30 @@ export function DynamicPricingBreakdown({
{condSummary} {condSummary}
</div> </div>
)} )}
</TableCell> </>
{visiblePriceFields.map((v) => { )
},
},
...visiblePriceFields.map((v, index) => ({
id: v.field ?? `price-${index}`,
header: t(v.shortLabel),
className: 'text-muted-foreground py-2 text-right font-medium',
cellClassName: 'py-2.5 text-right align-top font-mono',
cell: (tier: ParsedTier) => {
const value = Number( const value = Number(
tier[v.field as string as keyof ParsedTier] || 0 tier[v.field as string as keyof ParsedTier] || 0
) )
return ( return value > 0 ? (
<TableCell
key={v.field}
className='py-2.5 text-right align-top font-mono'
>
{value > 0 ? (
<span className='font-semibold'> <span className='font-semibold'>
{`${symbol}${(value * rate).toFixed(4)}`} {`${symbol}${(value * rate).toFixed(4)}`}
</span> </span>
) : ( ) : (
'-' '-'
)}
</TableCell>
) )
})} },
</TableRow> })),
) ]}
})} />
</TableBody>
</Table>
</div>
</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,24 +566,21 @@ 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'> <code className='font-mono text-sm font-medium'>{p.name}</code>
{p.name}
</code>
{p.required && ( {p.required && (
<Badge <Badge
variant='outline' variant='outline'
@@ -597,26 +590,38 @@ function SupportedParametersSection(props: { model: PricingModel }) {
</Badge> </Badge>
)} )}
</div> </div>
</TableCell> ),
<TableCell className='py-2 align-top'> },
{
id: 'type',
header: t('Type'),
className: 'h-9 w-24',
cellClassName: tableStyles.topCell,
cell: (p) => (
<Badge <Badge
variant='secondary' variant='secondary'
className='h-7 rounded-full px-2.5 font-mono text-sm font-normal' className='h-7 rounded-full px-2.5 font-mono text-sm font-normal'
> >
{p.type} {p.type}
</Badge> </Badge>
</TableCell> ),
<TableCell className='py-2 align-top'> },
<ParamRangeCell param={p} /> {
</TableCell> id: 'range',
<TableCell className='text-muted-foreground py-2 align-top'> header: t('Default / range'),
{t(p.descriptionKey)} className: 'h-9 w-32',
</TableCell> cellClassName: tableStyles.topCell,
</TableRow> cell: (p) => <ParamRangeCell param={p} />,
))} },
</TableBody> {
</Table> id: 'description',
</div> 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,32 +158,26 @@ 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) => (
<TableRow key={`${app.rank}-${app.name}`}>
<TableCell className='py-2.5'>
<RankBadge rank={app.rank} />
</TableCell>
<TableCell className='py-2.5'>
<div className='flex items-center gap-3'> <div className='flex items-center gap-3'>
<span className='bg-muted text-muted-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md font-bold'> <span className='bg-muted text-muted-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md font-bold'>
{app.initial} {app.initial}
@@ -204,21 +191,37 @@ export function ModelDetailsApps(props: { model: PricingModel }) {
</p> </p>
</div> </div>
</div> </div>
</TableCell> ),
<TableCell className='text-muted-foreground hidden py-2.5 md:table-cell'> },
{app.category} {
</TableCell> id: 'category',
<TableCell className='py-2.5 text-right font-mono tabular-nums'> header: t('Category'),
{formatTokenVolume(app.monthly_tokens)} className: cn(
</TableCell> tableStyles.compactHeaderCell,
<TableCell className='py-2.5 text-right'> 'hidden md:table-cell'
<GrowthChip value={app.growth_pct} /> ),
</TableCell> cellClassName: cn(
</TableRow> tableStyles.compactMutedCell,
))} 'hidden md:table-cell'
</TableBody> ),
</Table> cell: (app) => app.category,
</div> },
{
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,27 +96,42 @@ 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>) => (
<tr>
<th
scope='row'
className='text-muted-foreground bg-muted/30 px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
>
{label}
</th>
{ALL_MODALITIES.map((modality) => {
const enabled = set.has(modality)
const Icon = MODALITY_META[modality].icon
return ( return (
<td <StaticDataTable
key={modality} className='rounded-lg'
className={cn( tableClassName='text-sm'
headerRowClassName='bg-muted/40'
data={[
{ label: t('Input'), set: inputSet },
{ label: t('Output'), set: outputSet },
]}
getRowKey={(row) => row.label}
columns={[
{
id: 'modality',
header: t('Modality'),
className:
'text-muted-foreground px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase',
cellClassName:
'text-muted-foreground bg-muted/30 px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase',
cell: (row) => row.label,
},
...ALL_MODALITIES.map((modality) => ({
id: modality,
header: t(MODALITY_META[modality].labelKey),
className:
'text-muted-foreground border-l px-3 py-2 text-center text-[11px] font-medium tracking-wider uppercase',
cellClassName: (row: { label: string; set: Set<Modality> }) =>
cn(
'border-l px-3 py-2 text-center', '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> }) => {
const enabled = row.set.has(modality)
const Icon = MODALITY_META[modality].icon
return (
<span <span
className={cn( className={cn(
'inline-flex items-center justify-center', 'inline-flex items-center justify-center',
@@ -135,39 +151,10 @@ export function ModalitiesMatrix(props: {
> >
<Icon className='size-4' /> <Icon className='size-4' />
</span> </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)}
</th>
))}
</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',
header: t('Success rate'),
className: cn(tableStyles.compactHeaderCell, 'min-w-[180px]'),
cellClassName: tableStyles.compactCell,
cell: (perf) => (
<UptimeSparkline <UptimeSparkline
size='sm' size='sm'
series={uptimeByGroup[perf.group] ?? []} series={uptimeByGroup[perf.group] ?? []}
/> />
</TableCell> ),
</TableRow> },
))} ]}
</TableBody> />
</Table>
</div>
</section> </section>
<section> <section>
+161 -171
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(
dynamicTiers
.flatMap((tier) =>
getDynamicPriceEntries(tier, {
tokenUnit: props.tokenUnit, tokenUnit: props.tokenUnit,
showRechargePrice, showRechargePrice,
priceRate: props.priceRate, priceRate: props.priceRate,
usdExchangeRate: props.usdExchangeRate, usdExchangeRate: props.usdExchangeRate,
groupRatioMultiplier: 1, groupRatioMultiplier: 1,
}) })
) const formattedPricesByGroup = new Map(
.map((entry) => [entry.field, entry]) availableGroups.map((group) => {
).values() const ratio = props.groupRatio[group] || 1
return [
group,
getDynamicFormattedPricesByTier(dynamicTiers, {
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'>
+27 -69
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
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</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} key={row.id}
onClick={() => handleRowClick(row.original)} row={row}
className='hover:bg-muted/30 cursor-pointer transition-colors' className='hover:bg-muted/30 cursor-pointer transition-colors'
> onClick={() => handleRowClick(row.original)}
{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: { enableRowSelection: true,
sorting,
columnVisibility,
rowSelection,
columnFilters, columnFilters,
globalFilter, globalFilter,
pagination, pagination,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
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,64 +238,51 @@ 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>
</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 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}>
<TableCell>
<TableId value={sub.id} />
</TableCell>
<TableCell>
<div> <div>
<div className='font-medium'> <div className='font-medium'>
{planTitleMap.get(sub.plan_id) || {planTitleMap.get(sub.plan_id) || `#${sub.plan_id}`}
`#${sub.plan_id}`}
</div> </div>
<div className='text-muted-foreground text-sm'> <div className='text-muted-foreground text-sm'>
{t('Source')}: {sub.source || '-'} {t('Source')}: {sub.source || '-'}
</div> </div>
</div> </div>
</TableCell> )
<TableCell> },
<SubscriptionStatusBadge sub={sub} t={t} /> },
</TableCell> {
<TableCell> id: 'status',
header: t('Status'),
cell: (record) => (
<SubscriptionStatusBadge sub={record.subscription} t={t} />
),
},
{
id: 'validity',
header: t('Validity'),
cell: (record) => {
const sub = record.subscription
return (
<div className='text-sm'> <div className='text-sm'>
<div> <div>
{t('Start')}: {formatTimestamp(sub.start_time)} {t('Start')}: {formatTimestamp(sub.start_time)}
@@ -311,11 +291,32 @@ export function UserSubscriptionsDialog(props: Props) {
{t('End')}: {formatTimestamp(sub.end_time)} {t('End')}: {formatTimestamp(sub.end_time)}
</div> </div>
</div> </div>
</TableCell> )
<TableCell> },
{total > 0 ? `${used}/${total}` : t('Unlimited')} },
</TableCell> {
<TableCell className='text-right'> id: 'quota',
header: t('Total Quota'),
cell: (record) => {
const sub = record.subscription
const total = Number(sub.amount_total || 0)
const used = Number(sub.amount_used || 0)
return total > 0 ? `${used}/${total}` : t('Unlimited')
},
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (record) => {
const sub = record.subscription
const now = Date.now() / 1000
const isExpired =
(sub.end_time || 0) > 0 && sub.end_time < now
const isActive = sub.status === 'active' && !isExpired
return (
<div className='flex justify-end gap-1'> <div className='flex justify-end gap-1'>
<Button <Button
size='sm' size='sm'
@@ -343,14 +344,11 @@ export function UserSubscriptionsDialog(props: Props) {
{t('Delete')} {t('Delete')}
</Button> </Button>
</div> </div>
</TableCell>
</TableRow>
) )
}) },
)} },
</TableBody> ]}
</Table> />
</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
/> />
) )
} }
+6 -2
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,8 +52,9 @@ function SubscriptionsContent() {
</div> </div>
</SectionPageLayout.Actions> </SectionPageLayout.Actions>
<SectionPageLayout.Content> <SectionPageLayout.Content>
<div className='flex h-full min-h-0 flex-col gap-4'>
{!complianceConfirmed ? ( {!complianceConfirmed ? (
<Alert variant='destructive' className='mb-4'> <Alert variant='destructive' className='shrink-0'>
<AlertDescription> <AlertDescription>
{t( {t(
'Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.' 'Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.'
@@ -61,7 +62,10 @@ function SubscriptionsContent() {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : null}
<div className='min-h-0 flex-1'>
<SubscriptionsTable /> <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,51 +57,62 @@ 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>
<TableHead>{t('Client ID')}</TableHead>
<TableHead className='text-right'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{props.providers.map((provider) => (
<TableRow key={provider.id}>
<TableCell>
{provider.icon ? (
<span className='text-lg'>{provider.icon}</span> <span className='text-lg'>{provider.icon}</span>
) : ( ) : (
<span className='text-muted-foreground text-sm'>--</span> <span className='text-muted-foreground text-sm'>--</span>
)} ),
</TableCell> },
<TableCell className='font-medium'>{provider.name}</TableCell> {
<TableCell> id: 'name',
header: t('Name'),
cellClassName: 'font-medium',
cell: (provider) => provider.name,
},
{
id: 'slug',
header: t('Slug'),
cell: (provider) => (
<StatusBadge <StatusBadge
label={provider.slug} label={provider.slug}
variant='neutral' variant='neutral'
copyable={false} copyable={false}
/> />
</TableCell> ),
<TableCell> },
{
id: 'status',
header: t('Status'),
cell: (provider) => (
<StatusBadge <StatusBadge
label={provider.enabled ? t('Enabled') : t('Disabled')} label={provider.enabled ? t('Enabled') : t('Disabled')}
variant={provider.enabled ? 'success' : 'neutral'} variant={provider.enabled ? 'success' : 'neutral'}
copyable={false} copyable={false}
/> />
</TableCell> ),
<TableCell className='text-muted-foreground max-w-[120px] truncate font-mono'> },
{provider.client_id} {
</TableCell> id: 'client-id',
<TableCell className='text-right'> header: t('Client ID'),
cellClassName: 'text-muted-foreground max-w-[120px] truncate font-mono',
cell: (provider) => provider.client_id,
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (provider) => (
<div className='flex justify-end gap-1'> <div className='flex justify-end gap-1'>
<Button <Button
variant='ghost' variant='ghost'
@@ -125,12 +129,10 @@ export function ProviderTable(props: ProviderTableProps) {
<Trash2 className='text-destructive h-4 w-4' /> <Trash2 className='text-destructive h-4 w-4' />
</Button> </Button>
</div> </div>
</TableCell> ),
</TableRow> },
))} ]}
</TableBody> />
</Table>
)}
<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,11 +343,16 @@ 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.'
)}
columns={[
{
id: 'select',
header: (
<Checkbox <Checkbox
checked={ checked={
selectedIds.length === announcements.length && selectedIds.length === announcements.length &&
@@ -362,41 +360,27 @@ export function AnnouncementsSection({
} }
onCheckedChange={toggleSelectAll} onCheckedChange={toggleSelectAll}
/> />
</TableHead> ),
<TableHead>{t('Content')}</TableHead> className: 'w-12',
<TableHead>{t('Publish Date')}</TableHead> cell: (announcement) => (
<TableHead>{t('Type')}</TableHead>
<TableHead>{t('Extra')}</TableHead>
<TableHead className='w-32'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedAnnouncements.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className='h-24 text-center'>
{t(
'No announcements yet. Click "Add Announcement" to create one.'
)}
</TableCell>
</TableRow>
) : (
sortedAnnouncements.map((announcement) => (
<TableRow key={announcement.id}>
<TableCell>
<Checkbox <Checkbox
checked={selectedIds.includes(announcement.id)} checked={selectedIds.includes(announcement.id)}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
toggleSelectOne(announcement.id, checked as boolean) toggleSelectOne(announcement.id, checked as boolean)
} }
/> />
</TableCell> ),
<TableCell },
className='max-w-xs truncate' {
title={announcement.content} id: 'content',
> header: t('Content'),
{announcement.content} cellClassName: 'max-w-xs truncate',
</TableCell> cell: (announcement) => announcement.content,
<TableCell> },
{
id: 'publish-date',
header: t('Publish Date'),
cell: (announcement) => (
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
<span className='text-sm font-medium'> <span className='text-sm font-medium'>
{getRelativeTime(announcement.publishDate)} {getRelativeTime(announcement.publishDate)}
@@ -407,29 +391,36 @@ export function AnnouncementsSection({
)} )}
</span> </span>
</div> </div>
</TableCell> ),
<TableCell> },
{
id: 'type',
header: t('Type'),
cell: (announcement) => (
<StatusBadge <StatusBadge
label={ label={
typeOptions.find( typeOptions.find((opt) => opt.value === announcement.type)
(opt) => opt.value === announcement.type ?.label
)?.label
} }
variant={ variant={
typeOptions.find( typeOptions.find((opt) => opt.value === announcement.type)
(opt) => opt.value === announcement.type ?.badgeVariant ?? 'neutral'
)?.badgeVariant ?? 'neutral'
} }
copyable={false} copyable={false}
/> />
</TableCell> ),
<TableCell },
className='text-muted-foreground max-w-xs truncate' {
title={announcement.extra} id: 'extra',
> header: t('Extra'),
{announcement.extra || '-'} cellClassName: 'text-muted-foreground max-w-xs truncate',
</TableCell> cell: (announcement) => announcement.extra || '-',
<TableCell> },
{
id: 'actions',
header: t('Actions'),
className: 'w-32',
cell: (announcement) => (
<div className='flex gap-2'> <div className='flex gap-2'>
<Button <Button
onClick={() => handleEdit(announcement)} onClick={() => handleEdit(announcement)}
@@ -446,13 +437,10 @@ export function AnnouncementsSection({
<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
@@ -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,11 +299,14 @@ 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={[
{
id: 'select',
header: (
<Checkbox <Checkbox
checked={ checked={
selectedIds.length === apiInfoList.length && selectedIds.length === apiInfoList.length &&
@@ -318,66 +314,63 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
} }
onCheckedChange={toggleSelectAll} onCheckedChange={toggleSelectAll}
/> />
</TableHead> ),
<TableHead>{t('URL')}</TableHead> className: 'w-12',
<TableHead>{t('Route')}</TableHead> cell: (apiInfo) => (
<TableHead>{t('Description')}</TableHead>
<TableHead>{t('Color')}</TableHead>
<TableHead className='w-32'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiInfoList.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className='h-24 text-center'>
{t('No API Domains yet. Click "Add API" to create one.')}
</TableCell>
</TableRow>
) : (
apiInfoList.map((apiInfo) => (
<TableRow key={apiInfo.id}>
<TableCell>
<Checkbox <Checkbox
checked={selectedIds.includes(apiInfo.id)} checked={selectedIds.includes(apiInfo.id)}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
toggleSelectOne(apiInfo.id, checked as boolean) toggleSelectOne(apiInfo.id, checked as boolean)
} }
/> />
</TableCell> ),
<TableCell },
className='max-w-xs truncate font-mono text-sm' {
title={apiInfo.url} id: 'url',
> header: t('URL'),
cellClassName: 'max-w-xs truncate font-mono text-sm',
cell: (apiInfo) => (
<StatusBadge <StatusBadge
label={apiInfo.url} label={apiInfo.url}
variant='neutral' variant='neutral'
copyable={false} copyable={false}
/> />
</TableCell> ),
<TableCell> },
{
id: 'route',
header: t('Route'),
cell: (apiInfo) => (
<StatusBadge <StatusBadge
label={apiInfo.route} label={apiInfo.route}
variant='neutral' variant='neutral'
copyable={false} copyable={false}
/> />
</TableCell> ),
<TableCell },
className='max-w-xs truncate' {
title={apiInfo.description} id: 'description',
> header: t('Description'),
{apiInfo.description} cellClassName: 'max-w-xs truncate',
</TableCell> cell: (apiInfo) => apiInfo.description,
<TableCell> },
{
id: 'color',
header: t('Color'),
cell: (apiInfo) => (
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<div <div
className={`h-4 w-4 rounded-full ${getColorClass(apiInfo.color)}`} className={`h-4 w-4 rounded-full ${getColorClass(apiInfo.color)}`}
/> />
<span className='text-sm capitalize'> <span className='text-sm capitalize'>{apiInfo.color}</span>
{apiInfo.color}
</span>
</div> </div>
</TableCell> ),
<TableCell> },
{
id: 'actions',
header: t('Actions'),
className: 'w-32',
cell: (apiInfo) => (
<div className='flex gap-2'> <div className='flex gap-2'>
<Button <Button
onClick={() => handleEdit(apiInfo)} onClick={() => handleEdit(apiInfo)}
@@ -394,13 +387,10 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
<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
@@ -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,32 +142,35 @@ 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',
cell: (chat) => (
<div className='flex justify-end gap-2'> <div className='flex justify-end gap-2'>
<Button <Button
variant='ghost' variant='ghost'
@@ -191,13 +187,10 @@ export function ChatSettingsVisualEditor({
<Trash2 className='h-4 w-4' /> <Trash2 className='h-4 w-4' />
</Button> </Button>
</div> </div>
</TableCell> ),
</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,55 +262,48 @@ 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={[
{
id: 'select',
header: (
<Checkbox <Checkbox
checked={ checked={
selectedIds.length === faqList.length && selectedIds.length === faqList.length && faqList.length > 0
faqList.length > 0
} }
onCheckedChange={toggleSelectAll} onCheckedChange={toggleSelectAll}
/> />
</TableHead> ),
<TableHead>{t('Question')}</TableHead> className: 'w-12',
<TableHead>{t('Answer')}</TableHead> cell: (faq) => (
<TableHead className='w-32'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{faqList.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className='h-24 text-center'>
{t('No FAQ entries yet. Click "Add FAQ" to create one.')}
</TableCell>
</TableRow>
) : (
faqList.map((faq) => (
<TableRow key={faq.id}>
<TableCell>
<Checkbox <Checkbox
checked={selectedIds.includes(faq.id)} checked={selectedIds.includes(faq.id)}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
toggleSelectOne(faq.id, checked as boolean) toggleSelectOne(faq.id, checked as boolean)
} }
/> />
</TableCell> ),
<TableCell },
className='max-w-xs truncate font-medium' {
title={faq.question} id: 'question',
> header: t('Question'),
{faq.question} cellClassName: 'max-w-xs truncate font-medium',
</TableCell> cell: (faq) => faq.question,
<TableCell },
className='text-muted-foreground max-w-md truncate' {
title={faq.answer} id: 'answer',
> header: t('Answer'),
{faq.answer} cellClassName: 'text-muted-foreground max-w-md truncate',
</TableCell> cell: (faq) => faq.answer,
<TableCell> },
{
id: 'actions',
header: t('Actions'),
className: 'w-32',
cell: (faq) => (
<div className='flex gap-2'> <div className='flex gap-2'>
<Button <Button
onClick={() => handleEdit(faq)} onClick={() => handleEdit(faq)}
@@ -334,13 +320,10 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
<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
@@ -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,57 +271,57 @@ 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.'
)}
columns={[
{
id: 'select',
header: (
<Checkbox <Checkbox
checked={ checked={
selectedIds.length === groups.length && groups.length > 0 selectedIds.length === groups.length && groups.length > 0
} }
onCheckedChange={toggleSelectAll} onCheckedChange={toggleSelectAll}
/> />
</TableHead> ),
<TableHead>{t('Category Name')}</TableHead> className: 'w-12',
<TableHead>{t('Uptime Kuma URL')}</TableHead> cell: (group) => (
<TableHead>{t('Status Page Slug')}</TableHead>
<TableHead className='w-32'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className='h-24 text-center'>
{t(
'No Uptime Kuma groups yet. Click "Add Group" to create one.'
)}
</TableCell>
</TableRow>
) : (
groups.map((group) => (
<TableRow key={group.id}>
<TableCell>
<Checkbox <Checkbox
checked={selectedIds.includes(group.id)} checked={selectedIds.includes(group.id)}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
toggleSelectOne(group.id, checked as boolean) toggleSelectOne(group.id, checked as boolean)
} }
/> />
</TableCell> ),
<TableCell className='font-medium'> },
{group.categoryName} {
</TableCell> id: 'category',
<TableCell header: t('Category Name'),
className='text-primary max-w-xs truncate font-mono text-sm' cellClassName: 'font-medium',
title={group.url} cell: (group) => group.categoryName,
> },
{group.url} {
</TableCell> id: 'url',
<TableCell className='text-muted-foreground font-mono text-sm'> header: t('Uptime Kuma URL'),
{group.slug} cellClassName:
</TableCell> 'text-primary max-w-xs truncate font-mono text-sm',
<TableCell> cell: (group) => group.url,
},
{
id: 'slug',
header: t('Status Page Slug'),
cellClassName: 'text-muted-foreground font-mono text-sm',
cell: (group) => group.slug,
},
{
id: 'actions',
header: t('Actions'),
className: 'w-32',
cell: (group) => (
<div className='flex gap-2'> <div className='flex gap-2'>
<Button <Button
onClick={() => handleEdit(group)} onClick={() => handleEdit(group)}
@@ -345,13 +338,10 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
<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,63 +539,57 @@ 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) => (
{t('No rules yet')}
</TableCell>
</TableRow>
) : (
rules.map((rule, idx) => (
<TableRow key={idx}>
<TableCell className='font-medium'>
{rule.name || '-'}
</TableCell>
<TableCell>
<RuleBadgeList items={rule.model_regex || []} />
</TableCell>
<TableCell>
<RuleBadgeList <RuleBadgeList
items={(rule.key_sources || []).map( items={(rule.key_sources || []).map(
(src) => (src) =>
`${src.type}:${src.type === 'gjson' ? src.path : src.key}` `${src.type}:${src.type === 'gjson' ? src.path : src.key}`
)} )}
/> />
</TableCell> ),
<TableCell>{rule.ttl_seconds || '-'}</TableCell> },
<TableCell> {
id: 'ttl',
header: t('TTL'),
cell: (rule) => rule.ttl_seconds || '-',
},
{
id: 'retry',
header: t('Retry'),
cell: (rule) => (
<StatusBadge <StatusBadge
label={ label={
rule.skip_retry_on_failure rule.skip_retry_on_failure ? t('No Retry') : t('Retry')
? t('No Retry')
: t('Retry')
}
variant={
rule.skip_retry_on_failure ? 'danger' : 'neutral'
} }
variant={rule.skip_retry_on_failure ? 'danger' : 'neutral'}
copyable={false} copyable={false}
/> />
</TableCell> ),
<TableCell> },
{(() => { {
id: 'scope',
header: t('Scope'),
cell: (rule) => {
const scopeItems = [ const scopeItems = [
rule.include_using_group && t('Group'), rule.include_using_group && t('Group'),
rule.include_model_name && t('Model'), rule.include_model_name && t('Model'),
@@ -610,14 +597,22 @@ export function ChannelAffinitySection(props: Props) {
].filter(Boolean) as string[] ].filter(Boolean) as string[]
if (scopeItems.length === 0) return '-' if (scopeItems.length === 0) return '-'
return <RuleBadgeList items={scopeItems} /> return <RuleBadgeList items={scopeItems} />
})()} },
</TableCell> },
<TableCell> {
{rule.include_rule_name && cacheStats?.by_rule_name id: 'cache',
header: t('Cache'),
cell: (rule) =>
rule.include_rule_name && cacheStats?.by_rule_name
? cacheStats.by_rule_name[rule.name] || 0 ? cacheStats.by_rule_name[rule.name] || 0
: 'N/A'} : 'N/A',
</TableCell> },
<TableCell className='text-right'> {
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (rule, idx) => (
<div className='flex justify-end gap-1'> <div className='flex justify-end gap-1'>
{rule.include_rule_name && ( {rule.include_rule_name && (
<Button <Button
@@ -651,13 +646,10 @@ export function ChannelAffinitySection(props: Props) {
<Trash2 className='h-3 w-3' /> <Trash2 className='h-3 w-3' />
</Button> </Button>
</div> </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,30 +140,33 @@ 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>
<TableBody>
{discounts.map((discount) => (
<TableRow key={discount.amount}>
<TableCell>
<span className='font-mono text-sm'> <span className='font-mono text-sm'>
${discount.amount} ${discount.amount}
</span> </span>
</TableCell> ),
<TableCell> },
{
id: 'discount-rate',
header: t('Discount Rate'),
cell: (discount) => (
<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> },
{
id: 'discount',
header: t('Discount'),
cell: (discount) => (
<StatusBadge <StatusBadge
variant={discount.discountRate < 1 ? 'info' : 'neutral'} variant={discount.discountRate < 1 ? 'info' : 'neutral'}
className='font-mono' className='font-mono'
@@ -178,8 +174,14 @@ export function AmountDiscountVisualEditor({
> >
{formatPercentage(discount.discountRate)} {t('off')} {formatPercentage(discount.discountRate)} {t('off')}
</StatusBadge> </StatusBadge>
</TableCell> ),
<TableCell className='text-right'> },
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (discount) => (
<div className='flex justify-end gap-2'> <div className='flex justify-end gap-2'>
<Button <Button
type='button' type='button'
@@ -206,12 +208,10 @@ export function AmountDiscountVisualEditor({
<Trash2 className='h-4 w-4' /> <Trash2 className='h-4 w-4' />
</Button> </Button>
</div> </div>
</TableCell> ),
</TableRow> },
))} ]}
</TableBody> />
</Table>
</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,39 +176,50 @@ 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}
</TableCell>
<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'>
{product.productId} {product.productId}
</code> </code>
</TableCell> ),
<TableCell> },
{
id: 'price',
header: t('Price'),
cell: (product) => (
<span className='font-mono text-sm'> <span className='font-mono text-sm'>
{formatCreemPrice(product.price, product.currency)} {formatCreemPrice(product.price, product.currency)}
</span> </span>
</TableCell> ),
<TableCell> },
{
id: 'quota',
header: t('Quota'),
cell: (product) => (
<span className='font-mono text-sm'> <span className='font-mono text-sm'>
{formatQuotaShort(product.quota)} {formatQuotaShort(product.quota)}
</span> </span>
</TableCell> ),
<TableCell className='text-right'> },
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (product) => (
<div className='flex justify-end gap-2'> <div className='flex justify-end gap-2'>
<Button <Button
type='button' type='button'
@@ -242,12 +246,10 @@ export function CreemProductsVisualEditor({
<Trash2 className='h-4 w-4' /> <Trash2 className='h-4 w-4' />
</Button> </Button>
</div> </div>
</TableCell> ),
</TableRow> },
))} ]}
</TableBody> />
</Table>
</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,31 +286,33 @@ 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',
const colorPreview = getColorPreview(method.color) header: t('Type'),
return ( cell: (method) => (
<TableRow key={`${method.type}-${index}`}>
<TableCell className='font-medium'>
{method.name}
</TableCell>
<TableCell>
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'> <code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
{method.type} {method.type}
</code> </code>
</TableCell> ),
<TableCell> },
{
id: 'color',
header: t('Color'),
cell: (method) => {
const colorPreview = getColorPreview(method.color)
return (
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
{colorPreview && ( {colorPreview && (
<div <div
@@ -327,19 +324,27 @@ export function PaymentMethodsVisualEditor({
{method.color} {method.color}
</span> </span>
</div> </div>
</TableCell> )
<TableCell> },
{method.min_topup ? ( },
{
id: 'min-top-up',
header: t('Min Top-up'),
cell: (method) =>
method.min_topup ? (
<span className='font-mono text-sm'> <span className='font-mono text-sm'>
{method.min_topup} {method.min_topup}
</span> </span>
) : ( ) : (
<span className='text-muted-foreground text-sm'> <span className='text-muted-foreground text-sm'></span>
),
</span> },
)} {
</TableCell> id: 'actions',
<TableCell className='text-right'> header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (method) => (
<div className='flex justify-end gap-2'> <div className='flex justify-end gap-2'>
<Button <Button
type='button' type='button'
@@ -366,13 +371,10 @@ export function PaymentMethodsVisualEditor({
<Trash2 className='h-4 w-4' /> <Trash2 className='h-4 w-4' />
</Button> </Button>
</div> </div>
</TableCell> ),
</TableRow> },
) ]}
})} />
</TableBody>
</Table>
</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,33 +326,21 @@ 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}
className='text-muted-foreground py-8 text-center'
>
{t('No payment methods configured')}
</TableCell>
</TableRow>
) : (
payMethods.map((m, idx) => (
<TableRow key={idx}>
<TableCell>{m.name}</TableCell>
<TableCell>
{m.icon ? (
<img <img
src={m.icon} src={m.icon}
alt={m.name} alt={m.name}
@@ -367,11 +348,24 @@ export function WaffoSettingsSection({
/> />
) : ( ) : (
<span className='text-muted-foreground'>-</span> <span className='text-muted-foreground'>-</span>
)} ),
</TableCell> },
<TableCell>{m.payMethodType || '-'}</TableCell> {
<TableCell>{m.payMethodName || '-'}</TableCell> id: 'type',
<TableCell className='text-right'> 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'> <div className='flex justify-end gap-1'>
<Button <Button
type='button' type='button'
@@ -396,13 +390,10 @@ export function WaffoSettingsSection({
<Trash2 className='h-3 w-3' /> <Trash2 className='h-3 w-3' />
</Button> </Button>
</div> </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> },
{
id: 'new',
header: t('Change To'),
cell: (conflict) => (
<pre className='text-sm whitespace-pre-wrap'> <pre className='text-sm whitespace-pre-wrap'>
{conflict.newVal} {conflict.newVal}
</pre> </pre>
</TableCell> ),
</TableRow> },
))} ]}
</TableBody> />
</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,25 +420,27 @@ 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',
cellClassName: 'text-right',
cell: (group) => (
<div className='flex justify-end gap-2'> <div className='flex justify-end gap-2'>
<Button <Button
variant='ghost' variant='ghost'
@@ -460,21 +455,16 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
variant='ghost' variant='ghost'
size='sm' size='sm'
onClick={() => onClick={() =>
handleSimpleDelete( handleSimpleDelete('topupGroupRatio', group.name)
'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,24 +531,28 @@ 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',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (override) => (
<div className='flex justify-end gap-2'> <div className='flex justify-end gap-2'>
<Button <Button
variant='ghost' variant='ghost'
@@ -585,11 +579,10 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
<Trash2 className='h-4 w-4' /> <Trash2 className='h-4 w-4' />
</Button> </Button>
</div> </div>
</TableCell> ),
</TableRow> },
))} ]}
</TableBody> />
</Table>
</div> </div>
)} )}
</CollapsibleContent> </CollapsibleContent>
@@ -858,46 +851,31 @@ 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')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className='text-muted-foreground h-20 text-center text-sm'
>
{t('No groups yet. Add a group to get started.')}
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={row._id}>
<TableCell>
<Input <Input
value={row.name} value={row.name}
onChange={(event) => onChange={(event) =>
updateRow(row._id, 'name', event.target.value) updateRow(row._id, 'name', event.target.value)
} }
aria-invalid={duplicateNames.includes( aria-invalid={duplicateNames.includes(row.name.trim())}
row.name.trim()
)}
/> />
</TableCell> ),
<TableCell> },
{
id: 'ratio',
header: t('Ratio'),
className: 'w-28',
cell: (row) => (
<Input <Input
type='number' type='number'
min={0} min={0}
@@ -911,8 +889,13 @@ function GroupPricingTable({
) )
} }
/> />
</TableCell> ),
<TableCell> },
{
id: 'selectable',
header: t('User selectable'),
className: 'w-28 text-center',
cell: (row) => (
<div className='flex justify-center'> <div className='flex justify-center'>
<Checkbox <Checkbox
checked={row.selectable} checked={row.selectable}
@@ -922,27 +905,33 @@ function GroupPricingTable({
aria-label={t('User selectable')} aria-label={t('User selectable')}
/> />
</div> </div>
</TableCell> ),
<TableCell> },
{row.selectable ? ( {
id: 'description',
header: t('Description'),
className: 'min-w-56',
cell: (row) =>
row.selectable ? (
<Input <Input
value={row.description} value={row.description}
placeholder={t('Group description')} placeholder={t('Group description')}
onChange={(event) => onChange={(event) =>
updateRow( updateRow(row._id, 'description', event.target.value)
row._id,
'description',
event.target.value
)
} }
/> />
) : ( ) : (
<span className='text-muted-foreground px-3 text-sm'> <span className='text-muted-foreground px-3 text-sm'>
- -
</span> </span>
)} ),
</TableCell> },
<TableCell className='text-right'> {
id: 'actions',
header: t('Actions'),
className: 'w-16 text-right',
cellClassName: 'text-right',
cell: (row) => (
<Button <Button
variant='ghost' variant='ghost'
size='sm' size='sm'
@@ -951,13 +940,10 @@ function GroupPricingTable({
> >
<Trash2 className='h-4 w-4' /> <Trash2 className='h-4 w-4' />
</Button> </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) => (
<tr
key={row.id} key={row.id}
data-state={row.getIsSelected() ? 'selected' : undefined} row={row}
className={ className={
editData?.name === row.original.name editData?.name === row.original.name
? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors' ? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted group'
: 'hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors' : 'group'
}
getColumnClassName={(columnId) =>
columnId === 'actions' &&
editData?.name === row.original.name
? getCellClassName(columnId, 'bg-muted')
: getCellClassName(columnId)
} }
onClick={(event) => { onClick={(event) => {
const target = event.target as HTMLElement const target = event.target as HTMLElement
if (target.closest('button, [role="checkbox"]')) return if (target.closest('button, [role="checkbox"]')) return
handleEdit(row.original) handleEdit(row.original)
}} }}
> />
{row.getVisibleCells().map((cell) => (
<td
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,57 +253,45 @@ 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>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className='text-muted-foreground py-8 text-center'
>
{t('No tools configured')}
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<Input <Input
value={row.key} value={row.key}
placeholder='web_search_preview:gpt-4o*' placeholder='web_search_preview:gpt-4o*'
onChange={(e) => onChange={(e) => updateRow(row.id, 'key', e.target.value)}
updateRow(row.id, 'key', e.target.value)
}
/> />
</TableCell> ),
<TableCell> },
{
id: 'price',
header: t('Price ($/1K calls)'),
className: 'w-[200px]',
cell: (row) => (
<Input <Input
type='number' type='number'
min={0} min={0}
step={0.5} step={0.5}
value={row.price} value={row.price}
onChange={(e) => onChange={(e) =>
updateRow( updateRow(row.id, 'price', Number(e.target.value) || 0)
row.id,
'price',
Number(e.target.value) || 0
)
} }
/> />
</TableCell> ),
<TableCell className='text-right'> },
{
id: 'actions',
header: t('Actions'),
className: 'w-[80px] text-right',
cellClassName: 'text-right',
cell: (row) => (
<Button <Button
variant='ghost' variant='ghost'
size='icon' size='icon'
@@ -320,13 +300,10 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
> >
<Trash2 className='text-destructive h-4 w-4' /> <Trash2 className='text-destructive h-4 w-4' />
</Button> </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,46 +135,53 @@ 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>
{filteredRateLimits.map((limit) => (
<TableRow key={limit.groupName}>
<TableCell className='font-medium'>
{limit.groupName}
</TableCell>
<TableCell className='text-right'>
<span className='font-mono'> <span className='font-mono'>
{limit.maxRequests === 0 {limit.maxRequests === 0
? t('Unlimited') ? t('Unlimited')
: limit.maxRequests.toLocaleString()} : limit.maxRequests.toLocaleString()}
</span> </span>
</TableCell> ),
<TableCell className='text-right'> },
{
id: 'max-success',
header: t('Max Success'),
className: 'text-right',
cellClassName: 'text-right',
cell: (limit) => (
<span className='font-mono'> <span className='font-mono'>
{limit.maxSuccess.toLocaleString()} {limit.maxSuccess.toLocaleString()}
</span> </span>
</TableCell> ),
<TableCell className='text-right'> },
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (limit) => (
<div className='flex justify-end gap-2'> <div className='flex justify-end gap-2'>
<Button <Button
variant='ghost' variant='ghost'
@@ -198,13 +198,10 @@ export function RateLimitVisualEditor({
<Trash2 className='h-4 w-4' /> <Trash2 className='h-4 w-4' />
</Button> </Button>
</div> </div>
</TableCell> ),
</TableRow> },
))} ]}
</TableBody> />
</Table>
</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>
) )
}} }}
/> />
+4 -2
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,8 +127,10 @@ function UsageLogsContent() {
</TabsList> </TabsList>
</Tabs> </Tabs>
)} )}
<div className='min-h-0 flex-1'>
<UsageLogsTable logCategory={activeCategory} /> <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 },
}, },
{ {
+6 -37
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: { enableRowSelection: true,
sorting,
columnVisibility,
rowSelection,
columnFilters, columnFilters,
globalFilter, globalFilter,
pagination, pagination,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
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)
} }
} }