Commit 68585568 by CaIon

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).
parent 3fcd741c
/*
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'
export const DATA_TABLE_VIEW_MODES = {
TABLE: 'table',
CARD: 'card',
} as const
export type DataTableViewMode =
(typeof DATA_TABLE_VIEW_MODES)[keyof typeof DATA_TABLE_VIEW_MODES]
function isViewMode(value: unknown): value is DataTableViewMode {
return (
value === DATA_TABLE_VIEW_MODES.TABLE ||
value === DATA_TABLE_VIEW_MODES.CARD
)
}
function readViewMode(
storageKey: string | undefined,
fallback: DataTableViewMode
): DataTableViewMode {
if (!storageKey || typeof window === 'undefined') {
return fallback
}
try {
const raw = window.localStorage.getItem(storageKey)
return isViewMode(raw) ? raw : fallback
} catch {
return fallback
}
}
type UseDataTableViewModeOptions = {
/**
* localStorage key for persisting the selected view mode. When omitted the
* selection lives only in memory (resets on reload).
*/
storageKey?: string
/** Initial mode used when nothing is persisted. Defaults to `'table'`. */
defaultMode?: DataTableViewMode
}
/**
* View-mode (table vs. card) state with optional per-table localStorage
* persistence. Mirrors the SSR/try-catch guarded approach used for column
* visibility persistence in {@link useDataTable}.
*/
export function useDataTableViewMode(
options: UseDataTableViewModeOptions = {}
): [DataTableViewMode, (mode: DataTableViewMode) => void] {
const defaultMode = options.defaultMode ?? DATA_TABLE_VIEW_MODES.TABLE
const storageKey = options.storageKey
const [viewMode, setViewModeState] = React.useState<DataTableViewMode>(() =>
readViewMode(storageKey, defaultMode)
)
// Re-hydrate when the storage key changes (e.g. switching tables).
const hydratedStorageKeyRef = React.useRef(storageKey)
React.useEffect(() => {
if (storageKey === hydratedStorageKeyRef.current) {
return
}
hydratedStorageKeyRef.current = storageKey
setViewModeState(readViewMode(storageKey, defaultMode))
}, [storageKey, defaultMode])
const setViewMode = React.useCallback(
(mode: DataTableViewMode) => {
setViewModeState(mode)
if (!storageKey || typeof window === 'undefined') {
return
}
try {
window.localStorage.setItem(storageKey, mode)
} catch {
// Storage can be unavailable in private mode; controls still work.
}
},
[storageKey]
)
return [viewMode, setViewMode]
}
......@@ -38,10 +38,26 @@ export {
} from './core/data-table-view'
export { MobileCardList } from './layout/mobile-card-list'
export {
DataTableCardGrid,
type DataTableCardGridProps,
type DataTableCardHelpers,
} from './layout/card-grid'
export { CardRowContent } from './layout/card-row-content'
export { tableHasCompactMeta } from './layout/card-cell-utils'
export {
DataTablePage,
type DataTablePageProps,
} from './layout/data-table-page'
export {
DataTableViewModeToggle,
type DataTableViewModeToggleProps,
} from './toolbar/view-mode-toggle'
export { useDataTable } from './hooks/use-data-table'
export {
useDataTableViewMode,
DATA_TABLE_VIEW_MODES,
type DataTableViewMode,
} from './hooks/use-data-table-view-mode'
export { useDebouncedColumnFilter } from './hooks/use-debounced-column-filter'
export const DISABLED_ROW_DESKTOP =
......
/*
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 Cell, type Table } from '@tanstack/react-table'
import type { ReactNode } from 'react'
/**
* Shared cell helpers for the column-meta-driven card content used by both the
* mobile list and the desktop card grid. Kept separate from the card content
* component so the module exports only non-component utilities.
*/
export function getCellLabel<TData>(cell: Cell<TData, unknown>): string | null {
const { header, meta } = cell.column.columnDef
if (typeof header === 'string') {
return header
}
if (meta?.label) {
return meta.label
}
return null
}
export function renderCellContent<TData>(
cell: Cell<TData, unknown>
): ReactNode {
const cellRenderer = cell.column.columnDef.cell
if (cellRenderer) {
return flexRender(cellRenderer, cell.getContext())
}
return cell.getValue() as ReactNode
}
/**
* Whether any visible column declares `mobileTitle`/`mobileBadge` meta. When
* true the compact two-tier layout is used; otherwise the condensed
* label:value fallback layout is used.
*/
export function tableHasCompactMeta<TData>(table: Table<TData>): boolean {
return table.getVisibleLeafColumns().some((col) => {
const meta = col.columnDef.meta
return Boolean(meta?.mobileTitle || meta?.mobileBadge)
})
}
/*
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, Table } from '@tanstack/react-table'
import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
import { tableHasCompactMeta } from './card-cell-utils'
import { CardRowContent } from './card-row-content'
/** Helpers passed to a custom {@link DataTableCardGridProps.renderCard}. */
export type DataTableCardHelpers = {
/**
* Whether the table declares compact card meta (`mobileTitle`/`mobileBadge`).
* Provided so custom renderers can match the default layout decision.
*/
compact: boolean
}
export interface DataTableCardGridProps<TData> {
table: Table<TData>
isLoading?: boolean
emptyTitle?: string
emptyDescription?: string
emptyIcon?: React.ReactNode
getRowKey?: (row: Row<TData>) => string | number
getRowClassName?: (row: Row<TData>) => string | undefined
/**
* Custom card renderer. When omitted, cards render generically from the
* column definitions via {@link CardRowContent} (driven by column meta).
*/
renderCard?: (
row: Row<TData>,
helpers: DataTableCardHelpers
) => React.ReactNode
/**
* Responsive grid className override. Defaults to a 1/2/3-column grid.
*/
gridClassName?: string
/** Stable key prefix for skeleton cards. */
skeletonKeyPrefix?: string
}
const DEFAULT_GRID_CLASSNAME =
'grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3'
function CardGridSkeleton(props: {
gridClassName?: string
keyPrefix?: string
}) {
const prefix = props.keyPrefix ?? 'card-skeleton'
return (
<div className={props.gridClassName ?? DEFAULT_GRID_CLASSNAME}>
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={`${prefix}-${i}`}
className='bg-card space-y-3 rounded-lg border p-3'
>
<div className='flex items-center justify-between gap-2'>
<Skeleton className='h-4 w-32' />
<Skeleton className='h-5 w-16 rounded-md' />
</div>
<div className='grid grid-cols-2 gap-x-3 gap-y-1.5'>
{[1, 2, 3, 4].map((j) => (
<div key={j}>
<Skeleton className='mb-1 h-2 w-8' />
<Skeleton className='h-4 w-full' />
</div>
))}
</div>
</div>
))}
</div>
)
}
/**
* Desktop card view for table data — a responsive grid of bordered cards.
*
* Renders the same per-row content as {@link MobileCardList} (via
* {@link CardRowContent}) unless a custom `renderCard` is supplied. This keeps
* the card view reusable across any table with zero per-feature work while
* still allowing a bespoke card design when desired.
*
* Selection (the `select` column) is intentionally not rendered in card mode;
* bulk selection remains a table-mode capability.
*/
export function DataTableCardGrid<TData>(props: DataTableCardGridProps<TData>) {
const { t } = useTranslation()
const resolvedEmptyTitle = props.emptyTitle ?? t('No Data')
const resolvedEmptyDescription =
props.emptyDescription ?? t('No data available')
const visibleColumns = props.table.getVisibleLeafColumns()
const compact = React.useMemo(
() => tableHasCompactMeta(props.table),
// eslint-disable-next-line react-hooks/exhaustive-deps
[visibleColumns]
)
if (props.isLoading) {
return (
<CardGridSkeleton
gridClassName={props.gridClassName}
keyPrefix={props.skeletonKeyPrefix}
/>
)
}
const rows = props.table.getRowModel().rows
if (!rows || rows.length === 0) {
return (
<div className='rounded-lg border p-6'>
<Empty className='border-none p-0'>
<EmptyHeader>
<EmptyMedia variant='icon'>
{props.emptyIcon ?? <Database className='size-6' />}
</EmptyMedia>
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
)
}
return (
<div className={props.gridClassName ?? DEFAULT_GRID_CLASSNAME}>
{rows.map((row) => {
const key = props.getRowKey ? props.getRowKey(row) : row.id
return (
<div
key={key}
className={cn(
'bg-card rounded-lg border px-3 py-2.5',
props.getRowClassName?.(row)
)}
>
{props.renderCard ? (
props.renderCard(row, { compact })
) : (
<CardRowContent row={row} compact={compact} />
)}
</div>
)
})}
</div>
)
}
/*
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 { StatusBadgeTypeContext } from '@/components/status-badge'
import { getCellLabel, renderCellContent } from './card-cell-utils'
/**
* Shared, column-meta-driven card content rendering for TanStack rows.
*
* Both {@link MobileCardList} (mobile) and {@link DataTableCardGrid} (desktop
* card view) render the same inner content; only the surrounding container
* differs (single bordered list vs. responsive grid of cards). Keeping the
* per-row content here guarantees the two stay visually consistent.
*
* Column meta extensions (see `card-cell-utils.ts`):
* - `mobileTitle` — card header (left, larger text)
* - `mobileBadge` — inline with title (right, e.g. status badge)
* - `mobileHidden` — hidden in card content
*/
/**
* Compact content — structured layout with title header + side-by-side fields.
* Used when columns define mobileTitle or mobileBadge meta.
*
* Visual structure:
* [Title content] [Badge]
* [Field1 label] [Field2 label]
* [Field1 value] [Field2 value]
* [Actions ⋯]
*/
function CompactContent<TData>({ row }: { row: Row<TData> }) {
const allCells = row
.getVisibleCells()
.filter((cell) => cell.column.id !== 'select')
// Read each cell's meta once, then reuse for all categorisation checks.
const cellMetas = React.useMemo(
() => allCells.map((c) => c.column.columnDef.meta),
// eslint-disable-next-line react-hooks/exhaustive-deps
[allCells.map((c) => c.id).join(',')]
)
const titleCell = allCells.find((_, i) => cellMetas[i]?.mobileTitle)
const badgeCell = allCells.find((_, i) => cellMetas[i]?.mobileBadge)
const actionsCell = allCells.find((c) => c.column.id === 'actions')
const fieldCells = allCells.filter(
(c, i) =>
c !== titleCell &&
c !== badgeCell &&
c !== actionsCell &&
!cellMetas[i]?.mobileHidden
)
return (
<>
{/* Row 1: Title + Badge */}
<div className='flex items-center justify-between gap-2'>
{titleCell && (
<div className='min-w-0 flex-1 text-sm font-medium [&_[data-slot=status-badge]]:max-w-full [&_[data-slot=status-badge]]:whitespace-normal'>
{renderCellContent(titleCell)}
</div>
)}
{badgeCell && (
<div className='flex-none [&_[data-slot=status-badge]]:max-w-none'>
{renderCellContent(badgeCell)}
</div>
)}
</div>
{/* Row 2: Key fields wrap into compact columns instead of squeezing */}
{fieldCells.length > 0 && (
<div className='mt-1.5 grid grid-cols-2 gap-x-3 gap-y-1.5'>
{fieldCells.map((cell) => {
const label = getCellLabel(cell)
return (
<div key={cell.id} className='min-w-0 flex-1 overflow-hidden'>
{label && (
<div className='text-muted-foreground mb-0.5 text-[10px] leading-none select-none'>
{label}
</div>
)}
<div className='min-w-0 overflow-hidden text-xs [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'>
<StatusBadgeTypeContext.Provider value='text'>
{renderCellContent(cell) ?? '-'}
</StatusBadgeTypeContext.Provider>
</div>
</div>
)
})}
</div>
)}
{/* Actions */}
{actionsCell && (
<div className='mt-1 -mb-0.5 flex justify-end'>
{renderCellContent(actionsCell)}
</div>
)}
</>
)
}
/**
* Fallback content — condensed label:value pairs for tables without
* mobileTitle/mobileBadge. Still respects mobileHidden.
*/
function FallbackContent<TData>({ row }: { row: Row<TData> }) {
const allCells = row
.getVisibleCells()
.filter((cell) => cell.column.id !== 'select')
const cellMetas = React.useMemo(
() => allCells.map((c) => c.column.columnDef.meta),
// eslint-disable-next-line react-hooks/exhaustive-deps
[allCells.map((c) => c.id).join(',')]
)
const actionsCell = allCells.find((c) => c.column.id === 'actions')
const contentCells = allCells.filter(
(c, i) => c.column.id !== 'actions' && !cellMetas[i]?.mobileHidden
)
return (
<>
{contentCells.map((cell) => {
const label = getCellLabel(cell)
if (!label) {
return (
<div
key={cell.id}
className='flex justify-end overflow-hidden [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'
>
<StatusBadgeTypeContext.Provider value='text'>
{renderCellContent(cell)}
</StatusBadgeTypeContext.Provider>
</div>
)
}
return (
<div
key={cell.id}
className='flex items-start justify-between gap-2 overflow-hidden'
>
<span className='text-muted-foreground shrink-0 text-[10px] font-medium select-none'>
{label}
</span>
<div className='flex min-w-0 flex-1 items-center justify-end overflow-hidden text-xs [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'>
<StatusBadgeTypeContext.Provider value='text'>
{renderCellContent(cell) ?? '-'}
</StatusBadgeTypeContext.Provider>
</div>
</div>
)
})}
{actionsCell && (
<div className='-mb-0.5 flex justify-end pt-0.5'>
{renderCellContent(actionsCell)}
</div>
)}
</>
)
}
/**
* Renders a single row's card content, auto-selecting the compact or fallback
* layout. Callers compute `compact` once per table (via `tableHasCompactMeta`)
* and pass it down to avoid recomputation per row.
*/
export function CardRowContent<TData>(props: {
row: Row<TData>
compact: boolean
}) {
return props.compact ? (
<CompactContent row={props.row} />
) : (
<FallbackContent row={props.row} />
)
}
......@@ -33,7 +33,14 @@ import {
} from '../core/data-table-view'
import { DataTablePagination } from '../core/pagination'
import { DataTableToolbar } from '../toolbar/toolbar'
import { DataTableViewModeToggle } from '../toolbar/view-mode-toggle'
import {
DATA_TABLE_VIEW_MODES,
useDataTableViewMode,
type DataTableViewMode,
} from '../hooks/use-data-table-view-mode'
import { MobileCardList } from './mobile-card-list'
import { DataTableCardGrid } from './card-grid'
/**
* Pass-through configuration for the default {@link DataTableToolbar}.
......@@ -209,6 +216,53 @@ export type DataTablePageProps<TData> = {
* outside the scrollable body automatically.
*/
tableHeaderClassName?: string
/**
* Opt into the table/card view toggle. Defaults to `false`, so existing
* pages render the table only and behave exactly as before. When enabled, a
* {@link DataTableViewModeToggle} is injected into the default toolbar
* (requires `toolbarProps`; ignored when a fully custom `toolbar` is used)
* and the desktop view switches between the table and a card grid.
*
* The mobile layout is unaffected — it always renders the mobile list.
*/
enableCardView?: boolean
/**
* Controlled view mode. When provided, `onViewModeChange` should update it.
* Leave unset to let the page manage view mode internally (optionally
* persisted via `viewModeStorageKey`).
*/
viewMode?: DataTableViewMode
/**
* Change handler for the controlled `viewMode`.
*/
onViewModeChange?: (mode: DataTableViewMode) => void
/**
* localStorage key for persisting the (uncontrolled) view mode per table.
* Ignored when `viewMode` is controlled.
*/
viewModeStorageKey?: string
/**
* Initial (uncontrolled) view mode. Defaults to `'table'`.
*/
defaultViewMode?: DataTableViewMode
/**
* Custom card renderer for card view. When omitted, cards are generated
* generically from the column definitions (driven by column meta).
*/
renderCard?: React.ComponentProps<
typeof DataTableCardGrid<TData>
>['renderCard']
/**
* Responsive grid className override for the card view.
*/
cardGridClassName?: string
}
/**
......@@ -236,9 +290,21 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
const isMobile = useMediaQuery('(max-width: 640px)')
const showMobile = isMobile && !props.hideMobile
const toolbarNode = renderToolbar(props)
const [internalViewMode, setInternalViewMode] = useDataTableViewMode({
storageKey: props.viewModeStorageKey,
defaultMode: props.defaultViewMode,
})
const viewMode = props.viewMode ?? internalViewMode
const setViewMode = props.onViewModeChange ?? setInternalViewMode
const cardViewActive = !!props.enableCardView
const viewToggle = cardViewActive ? (
<DataTableViewModeToggle value={viewMode} onChange={setViewMode} />
) : undefined
const toolbarNode = renderToolbar(props, viewToggle)
const mobileNode = renderMobile(props, showMobile)
const desktopNode = renderDesktop(props, showMobile)
const desktopNode = renderDesktop(props, showMobile, cardViewActive, viewMode)
const paginationNode = renderPagination(props)
return (
......@@ -267,16 +333,24 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
}
function renderToolbar<TData>(
props: DataTablePageProps<TData>
props: DataTablePageProps<TData>,
viewToggle: React.ReactNode
): React.ReactNode {
if (props.toolbar !== undefined) {
// Fully custom toolbar: the consumer owns layout, including any toggle.
return props.toolbar
}
if (props.toolbarProps === null) {
return null
}
if (props.toolbarProps) {
return <DataTableToolbar table={props.table} {...props.toolbarProps} />
return (
<DataTableToolbar
table={props.table}
{...props.toolbarProps}
viewToggle={props.toolbarProps.viewToggle ?? viewToggle}
/>
)
}
return null
}
......@@ -323,13 +397,41 @@ function renderMobile<TData>(
function renderDesktop<TData>(
props: DataTablePageProps<TData>,
showMobile: boolean
showMobile: boolean,
cardViewActive: boolean,
viewMode: DataTableViewMode
): React.ReactNode {
if (showMobile) return null
const isFetchingOnly = props.isFetching && !props.isLoading
const fixedHeight = props.fixedHeight !== false
if (cardViewActive && viewMode === DATA_TABLE_VIEW_MODES.CARD) {
return (
<div
className={cn(
fixedHeight && 'min-h-0 flex-1 overflow-y-auto',
'transition-opacity duration-150',
isFetchingOnly && 'pointer-events-none opacity-60'
)}
>
<DataTableCardGrid
table={props.table}
isLoading={props.isLoading}
emptyTitle={props.emptyTitle}
emptyDescription={props.emptyDescription}
emptyIcon={props.emptyIcon}
renderCard={props.renderCard}
gridClassName={props.cardGridClassName}
skeletonKeyPrefix={props.skeletonKeyPrefix}
getRowClassName={(row) =>
props.getRowClassName?.(row, { isMobile: false })
}
/>
</div>
)
}
return (
<DataTableView
table={props.table}
......
......@@ -17,12 +17,7 @@ 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 {
flexRender,
type Cell,
type Row,
type Table,
} from '@tanstack/react-table'
import type { Row, Table } from '@tanstack/react-table'
import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
......@@ -34,7 +29,8 @@ import {
EmptyTitle,
} from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
import { StatusBadgeTypeContext } from '@/components/status-badge'
import { tableHasCompactMeta } from './card-cell-utils'
import { CardRowContent } from './card-row-content'
interface MobileCardListProps<TData> {
table: Table<TData>
......@@ -45,21 +41,6 @@ interface MobileCardListProps<TData> {
getRowClassName?: (row: Row<TData>) => string | undefined
}
function getCellLabel<TData>(cell: Cell<TData, unknown>): string | null {
const { header, meta } = cell.column.columnDef
if (typeof header === 'string') return header
if (meta?.label) return meta.label
return null
}
function renderCellContent<TData>(cell: Cell<TData, unknown>): React.ReactNode {
const cellRenderer = cell.column.columnDef.cell
if (cellRenderer) {
return flexRender(cellRenderer, cell.getContext())
}
return cell.getValue() as React.ReactNode
}
function ListSkeleton() {
return (
<div className='divide-y overflow-hidden rounded-lg border'>
......@@ -103,164 +84,14 @@ function FallbackListSkeleton() {
}
/**
* Compact list row — structured layout with title header + side-by-side fields.
* Used when columns define mobileTitle or mobileBadge meta.
*
* Visual structure per row:
* [Title content] [Badge]
* [Field1 label] [Field2 label]
* [Field1 value] [Field2 value]
* [Actions ⋯]
*/
function CompactRow<TData>({ row }: { row: Row<TData> }) {
const allCells = row
.getVisibleCells()
.filter((cell) => cell.column.id !== 'select')
// Read each cell's meta once, then reuse for all categorisation checks.
const cellMetas = React.useMemo(
() => allCells.map((c) => c.column.columnDef.meta),
// eslint-disable-next-line react-hooks/exhaustive-deps
[allCells.map((c) => c.id).join(',')]
)
const titleCell = allCells.find((_, i) => cellMetas[i]?.mobileTitle)
const badgeCell = allCells.find((_, i) => cellMetas[i]?.mobileBadge)
const actionsCell = allCells.find((c) => c.column.id === 'actions')
const fieldCells = allCells.filter(
(c, i) =>
c !== titleCell &&
c !== badgeCell &&
c !== actionsCell &&
!cellMetas[i]?.mobileHidden
)
return (
<>
{/* Row 1: Title + Badge */}
<div className='flex items-center justify-between gap-2'>
{titleCell && (
<div className='min-w-0 flex-1 text-sm font-medium [&_[data-slot=status-badge]]:max-w-full [&_[data-slot=status-badge]]:whitespace-normal'>
{renderCellContent(titleCell)}
</div>
)}
{badgeCell && (
<div className='flex-none [&_[data-slot=status-badge]]:max-w-none'>
{renderCellContent(badgeCell)}
</div>
)}
</div>
{/* Row 2: Key fields wrap into compact columns instead of squeezing */}
{fieldCells.length > 0 && (
<div className='mt-1.5 grid grid-cols-2 gap-x-3 gap-y-1.5'>
{fieldCells.map((cell) => {
const label = getCellLabel(cell)
return (
<div key={cell.id} className='min-w-0 flex-1 overflow-hidden'>
{label && (
<div className='text-muted-foreground mb-0.5 text-[10px] leading-none select-none'>
{label}
</div>
)}
<div className='min-w-0 overflow-hidden text-xs [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'>
<StatusBadgeTypeContext.Provider value='text'>
{renderCellContent(cell) ?? '-'}
</StatusBadgeTypeContext.Provider>
</div>
</div>
)
})}
</div>
)}
{/* Actions */}
{actionsCell && (
<div className='mt-1 -mb-0.5 flex justify-end'>
{renderCellContent(actionsCell)}
</div>
)}
</>
)
}
/**
* Fallback list row — condensed label:value pairs for tables without
* mobileTitle/mobileBadge. Still respects mobileHidden.
*/
function FallbackRow<TData>({ row }: { row: Row<TData> }) {
const allCells = row
.getVisibleCells()
.filter((cell) => cell.column.id !== 'select')
const cellMetas = React.useMemo(
() => allCells.map((c) => c.column.columnDef.meta),
// eslint-disable-next-line react-hooks/exhaustive-deps
[allCells.map((c) => c.id).join(',')]
)
const actionsCell = allCells.find((c) => c.column.id === 'actions')
const contentCells = allCells.filter(
(c, i) => c.column.id !== 'actions' && !cellMetas[i]?.mobileHidden
)
return (
<>
{contentCells.map((cell) => {
const label = getCellLabel(cell)
if (!label) {
return (
<div
key={cell.id}
className='flex justify-end overflow-hidden [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'
>
<StatusBadgeTypeContext.Provider value='text'>
{renderCellContent(cell)}
</StatusBadgeTypeContext.Provider>
</div>
)
}
return (
<div
key={cell.id}
className='flex items-start justify-between gap-2 overflow-hidden'
>
<span className='text-muted-foreground shrink-0 text-[10px] font-medium select-none'>
{label}
</span>
<div className='flex min-w-0 flex-1 items-center justify-end overflow-hidden text-xs [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'>
<StatusBadgeTypeContext.Provider value='text'>
{renderCellContent(cell) ?? '-'}
</StatusBadgeTypeContext.Provider>
</div>
</div>
)
})}
{actionsCell && (
<div className='-mb-0.5 flex justify-end pt-0.5'>
{renderCellContent(actionsCell)}
</div>
)}
</>
)
}
/**
* Mobile-optimized list view for table data.
*
* Renders rows inside a single bordered container with dividers —
* a Vercel/Stripe-style list rather than individual cards.
*
* Column meta extensions:
* - `mobileTitle` — card header (left, larger text)
* - `mobileBadge` — inline with title (right, e.g. status badge)
* - `mobileHidden` — hidden on mobile
*
* When mobileTitle or mobileBadge is set on any column, uses a structured
* two-tier layout: title+badge header, then 2 key fields side-by-side.
* Otherwise falls back to a condensed single-column label:value list.
* Per-row content is shared with the desktop card view via
* {@link CardRowContent}; see `card-row-content.tsx` for the column-meta
* extensions (`mobileTitle`, `mobileBadge`, `mobileHidden`).
*/
export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
const {
......@@ -278,11 +109,8 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
const visibleColumns = table.getVisibleLeafColumns()
const hasCompactMeta = React.useMemo(
() =>
visibleColumns.some((col) => {
const meta = col.columnDef.meta
return meta?.mobileTitle || meta?.mobileBadge
}),
() => tableHasCompactMeta(table),
// eslint-disable-next-line react-hooks/exhaustive-deps
[visibleColumns]
)
......@@ -308,8 +136,6 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
)
}
const RowComponent = hasCompactMeta ? CompactRow : FallbackRow
return (
<div className='divide-y overflow-hidden rounded-lg border'>
{rows.map((row) => {
......@@ -319,7 +145,7 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
key={key}
className={cn('bg-card px-3 py-2.5', getRowClassName?.(row))}
>
<RowComponent row={row} />
<CardRowContent row={row} compact={hasCompactMeta} />
</div>
)
})}
......
......@@ -116,6 +116,12 @@ export type DataTableToolbarProps<TData> = {
*/
hideViewOptions?: boolean
/**
* Optional view-mode toggle (e.g. table vs. card) rendered in the right
* action cluster, before the View Options dropdown. Typically a
* {@link DataTableViewModeToggle}. Omitted by default.
*/
viewToggle?: ReactNode
/**
* Content rendered on the LEFT side of the secondary action row. When
* provided the toolbar splits into two visual rows:
* Row 1: search inputs / filter chips …… Expand
......@@ -302,6 +308,8 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
<DataTableViewOptions table={props.table} />
) : null
const viewToggleNode = props.viewToggle ?? null
const expandToggle = hasExpandable ? (
<Button
variant='ghost'
......@@ -350,6 +358,7 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
{props.preActions}
{resetButton}
{searchButton}
{viewToggleNode}
{viewOptionsNode}
</div>
</div>
......@@ -373,6 +382,7 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
{props.preActions}
{resetButton}
{searchButton}
{viewToggleNode}
{viewOptionsNode}
{expandToggle}
</div>
......
/*
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 { Grid2X2, Table2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
DATA_TABLE_VIEW_MODES,
type DataTableViewMode,
} from '../hooks/use-data-table-view-mode'
export type DataTableViewModeToggleProps = {
value: DataTableViewMode
onChange: (mode: DataTableViewMode) => void
className?: string
}
type Segment = {
value: DataTableViewMode
icon: React.ComponentType<{ className?: string }>
tooltip: string
}
/**
* Reusable icon segmented control for switching a data table between table and
* card views. Shared, accessible version of the local control used by the
* model square (`pricing-toolbar.tsx`).
*/
export function DataTableViewModeToggle(props: DataTableViewModeToggleProps) {
const { t } = useTranslation()
const segments: Segment[] = [
{
value: DATA_TABLE_VIEW_MODES.TABLE,
icon: Table2,
tooltip: t('Table view'),
},
{
value: DATA_TABLE_VIEW_MODES.CARD,
icon: Grid2X2,
tooltip: t('Card view'),
},
]
return (
<div
role='group'
aria-label={t('View mode')}
className={cn(
'bg-muted/60 inline-flex h-8 items-center rounded-lg border p-0.5',
props.className
)}
>
{segments.map((segment) => {
const Icon = segment.icon
const isActive = segment.value === props.value
return (
<Tooltip key={segment.value}>
<TooltipTrigger
render={
<button
type='button'
onClick={() => props.onChange(segment.value)}
aria-pressed={isActive}
className={cn(
'inline-flex h-full w-7 items-center justify-center rounded-md text-xs font-medium transition-all',
isActive
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Icon className='size-3.5' />
</button>
}
/>
<TooltipContent side='bottom' className='text-xs'>
{segment.tooltip}
</TooltipContent>
</Tooltip>
)
})}
</div>
)
}
/*
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...'),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment