feat(data-table): add reusable card/table view toggle

Add an opt-in card view to the shared data-table stack (DataTablePage),
toggled via a segmented control in the toolbar with per-table localStorage
persistence. Cards render generically from column meta by default, with an
optional renderCard slot. Defaults to table-only so existing pages are
unchanged.

Wire it into the channels page with a bespoke ChannelCard that reuses every
column's cell renderer, preserving all table information and interactions
(selection, inline priority/weight, balance refresh, status, actions, tag
expand).
This commit is contained in:
CaIon
2026-06-19 17:43:11 +08:00
parent 3fcd741c6e
commit 68585568d6
11 changed files with 908 additions and 188 deletions
@@ -0,0 +1,122 @@
/*
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 Row } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { isTagAggregateRow } from '../lib'
import type { Channel } from '../types'
/**
* Field columns rendered in the card body (in display order). The header
* columns (`select`, `name`, `status`, `actions`) are laid out separately.
* `models` spans the full width because it can hold many badges.
*/
const FIELD_COLUMN_IDS = [
'type',
'id',
'group',
'balance',
'priority',
'weight',
'response_time',
'test_time',
'tag',
'models',
] as const
/**
* Bespoke channel card for the card view. Reuses every column's existing cell
* renderer via `flexRender`, so all table information and interactions are
* preserved: row selection, name/remark + warning icons, status (with tooltips),
* provider/multi-key/IO.NET badges, groups, models, tag, inline priority/weight
* spinners, balance refresh, response/test times, tag expand-collapse, and the
* per-row (or per-tag) actions menu.
*/
export function ChannelCard({ row }: { row: Row<Channel> }) {
const { t } = useTranslation()
const isTagRow = isTagAggregateRow(row.original)
const cells = row.getAllCells()
const renderCell = (id: string) => {
const cell = cells.find((c) => c.column.id === id)
if (!cell || !cell.column.columnDef.cell) {
return null
}
return flexRender(cell.column.columnDef.cell, cell.getContext())
}
const fieldLabels: Record<string, string> = {
type: t('Type'),
id: t('ID'),
group: t('Groups'),
balance: t('Used / Remaining'),
priority: t('Priority'),
weight: t('Weight'),
response_time: t('Response'),
test_time: t('Last Tested'),
tag: t('Tag'),
models: t('Models'),
}
const selectCell = renderCell('select')
const nameCell = renderCell('name')
const statusCell = renderCell('status')
const actionsCell = renderCell('actions')
return (
<div className='flex flex-col gap-3'>
{/* Header: selection + name/remark, with status badge + actions menu */}
<div className='flex items-start justify-between gap-2'>
<div className='flex min-w-0 flex-1 items-start gap-2'>
{!isTagRow && selectCell && (
<div className='pt-0.5'>{selectCell}</div>
)}
<div className='min-w-0 flex-1'>{nameCell}</div>
</div>
<div className='flex flex-shrink-0 items-center gap-1.5'>
{statusCell}
{actionsCell}
</div>
</div>
{/* Body: labelled fields for every remaining column */}
<div className='grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3'>
{FIELD_COLUMN_IDS.map((id) => {
const content = renderCell(id)
return (
<div
key={id}
className={cn(
'min-w-0',
id === 'models' && 'col-span-2 sm:col-span-3'
)}
>
<div className='text-muted-foreground mb-1 text-[11px] font-medium tracking-wide uppercase select-none'>
{fieldLabels[id]}
</div>
<div className='min-w-0 overflow-hidden text-sm'>
{content ?? <span className='text-muted-foreground'>-</span>}
</div>
</div>
)
})}
</div>
</div>
)
}
@@ -51,12 +51,14 @@ import {
} from '../lib'
import type { Channel, ChannelSortBy } from '../types'
import { useChannelsColumns } from './channels-columns'
import { ChannelCard } from './channel-card'
import { useChannels } from './channels-provider'
import { DataTableBulkActions } from './data-table-bulk-actions'
const route = getRouteApi('/_authenticated/channels/')
const CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY =
'channels:column-visibility'
const CHANNELS_VIEW_MODE_STORAGE_KEY = 'channels:view-mode'
const CHANNEL_SORTABLE_COLUMNS = new Set<ChannelSortBy>([
'id',
@@ -355,6 +357,10 @@ export function ChannelsTable() {
'No channels available. Create your first channel to get started.'
)}
skeletonKeyPrefix='channel-skeleton'
enableCardView
viewModeStorageKey={CHANNELS_VIEW_MODE_STORAGE_KEY}
renderCard={(row) => <ChannelCard row={row} />}
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-2 2xl:grid-cols-3'
applyHeaderSize
toolbarProps={{
searchPlaceholder: t('Filter by name, ID, or key...'),