mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-06 17:46:23 +00:00
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:
@@ -0,0 +1,103 @@
|
|||||||
|
/*
|
||||||
|
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]
|
||||||
|
}
|
||||||
+16
@@ -37,11 +37,27 @@ export {
|
|||||||
type DataTableRenderRowHelpers,
|
type DataTableRenderRowHelpers,
|
||||||
} from './core/data-table-view'
|
} from './core/data-table-view'
|
||||||
export { MobileCardList } from './layout/mobile-card-list'
|
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 {
|
export {
|
||||||
DataTablePage,
|
DataTablePage,
|
||||||
type DataTablePageProps,
|
type DataTablePageProps,
|
||||||
} from './layout/data-table-page'
|
} from './layout/data-table-page'
|
||||||
|
export {
|
||||||
|
DataTableViewModeToggle,
|
||||||
|
type DataTableViewModeToggleProps,
|
||||||
|
} from './toolbar/view-mode-toggle'
|
||||||
export { useDataTable } from './hooks/use-data-table'
|
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 { useDebouncedColumnFilter } from './hooks/use-debounced-column-filter'
|
||||||
|
|
||||||
export const DISABLED_ROW_DESKTOP =
|
export const DISABLED_ROW_DESKTOP =
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/*
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/*
|
||||||
|
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'
|
} from '../core/data-table-view'
|
||||||
import { DataTablePagination } from '../core/pagination'
|
import { DataTablePagination } from '../core/pagination'
|
||||||
import { DataTableToolbar } from '../toolbar/toolbar'
|
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 { MobileCardList } from './mobile-card-list'
|
||||||
|
import { DataTableCardGrid } from './card-grid'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pass-through configuration for the default {@link DataTableToolbar}.
|
* Pass-through configuration for the default {@link DataTableToolbar}.
|
||||||
@@ -209,6 +216,53 @@ export type DataTablePageProps<TData> = {
|
|||||||
* outside the scrollable body automatically.
|
* outside the scrollable body automatically.
|
||||||
*/
|
*/
|
||||||
tableHeaderClassName?: string
|
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 isMobile = useMediaQuery('(max-width: 640px)')
|
||||||
const showMobile = isMobile && !props.hideMobile
|
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 mobileNode = renderMobile(props, showMobile)
|
||||||
const desktopNode = renderDesktop(props, showMobile)
|
const desktopNode = renderDesktop(props, showMobile, cardViewActive, viewMode)
|
||||||
const paginationNode = renderPagination(props)
|
const paginationNode = renderPagination(props)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -267,16 +333,24 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderToolbar<TData>(
|
function renderToolbar<TData>(
|
||||||
props: DataTablePageProps<TData>
|
props: DataTablePageProps<TData>,
|
||||||
|
viewToggle: React.ReactNode
|
||||||
): React.ReactNode {
|
): React.ReactNode {
|
||||||
if (props.toolbar !== undefined) {
|
if (props.toolbar !== undefined) {
|
||||||
|
// Fully custom toolbar: the consumer owns layout, including any toggle.
|
||||||
return props.toolbar
|
return props.toolbar
|
||||||
}
|
}
|
||||||
if (props.toolbarProps === null) {
|
if (props.toolbarProps === null) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (props.toolbarProps) {
|
if (props.toolbarProps) {
|
||||||
return <DataTableToolbar table={props.table} {...props.toolbarProps} />
|
return (
|
||||||
|
<DataTableToolbar
|
||||||
|
table={props.table}
|
||||||
|
{...props.toolbarProps}
|
||||||
|
viewToggle={props.toolbarProps.viewToggle ?? viewToggle}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -323,13 +397,41 @@ function renderMobile<TData>(
|
|||||||
|
|
||||||
function renderDesktop<TData>(
|
function renderDesktop<TData>(
|
||||||
props: DataTablePageProps<TData>,
|
props: DataTablePageProps<TData>,
|
||||||
showMobile: boolean
|
showMobile: boolean,
|
||||||
|
cardViewActive: boolean,
|
||||||
|
viewMode: DataTableViewMode
|
||||||
): React.ReactNode {
|
): React.ReactNode {
|
||||||
if (showMobile) return null
|
if (showMobile) return null
|
||||||
|
|
||||||
const isFetchingOnly = props.isFetching && !props.isLoading
|
const isFetchingOnly = props.isFetching && !props.isLoading
|
||||||
const fixedHeight = props.fixedHeight !== false
|
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 (
|
return (
|
||||||
<DataTableView
|
<DataTableView
|
||||||
table={props.table}
|
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
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import {
|
import type { Row, Table } from '@tanstack/react-table'
|
||||||
flexRender,
|
|
||||||
type Cell,
|
|
||||||
type Row,
|
|
||||||
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 { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -34,7 +29,8 @@ import {
|
|||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from '@/components/ui/empty'
|
} from '@/components/ui/empty'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
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> {
|
interface MobileCardListProps<TData> {
|
||||||
table: Table<TData>
|
table: Table<TData>
|
||||||
@@ -45,21 +41,6 @@ interface MobileCardListProps<TData> {
|
|||||||
getRowClassName?: (row: Row<TData>) => string | undefined
|
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() {
|
function ListSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className='divide-y overflow-hidden rounded-lg border'>
|
<div className='divide-y overflow-hidden rounded-lg border'>
|
||||||
@@ -102,165 +83,15 @@ 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.
|
* Mobile-optimized list view for table data.
|
||||||
*
|
*
|
||||||
* Renders rows inside a single bordered container with dividers —
|
* Renders rows inside a single bordered container with dividers —
|
||||||
* a Vercel/Stripe-style list rather than individual cards.
|
* a Vercel/Stripe-style list rather than individual cards.
|
||||||
*
|
*
|
||||||
* Column meta extensions:
|
* Per-row content is shared with the desktop card view via
|
||||||
* - `mobileTitle` — card header (left, larger text)
|
* {@link CardRowContent}; see `card-row-content.tsx` for the column-meta
|
||||||
* - `mobileBadge` — inline with title (right, e.g. status badge)
|
* extensions (`mobileTitle`, `mobileBadge`, `mobileHidden`).
|
||||||
* - `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.
|
|
||||||
*/
|
*/
|
||||||
export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
||||||
const {
|
const {
|
||||||
@@ -278,11 +109,8 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
|||||||
|
|
||||||
const visibleColumns = table.getVisibleLeafColumns()
|
const visibleColumns = table.getVisibleLeafColumns()
|
||||||
const hasCompactMeta = React.useMemo(
|
const hasCompactMeta = React.useMemo(
|
||||||
() =>
|
() => tableHasCompactMeta(table),
|
||||||
visibleColumns.some((col) => {
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const meta = col.columnDef.meta
|
|
||||||
return meta?.mobileTitle || meta?.mobileBadge
|
|
||||||
}),
|
|
||||||
[visibleColumns]
|
[visibleColumns]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -308,8 +136,6 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const RowComponent = hasCompactMeta ? CompactRow : FallbackRow
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='divide-y overflow-hidden rounded-lg border'>
|
<div className='divide-y overflow-hidden rounded-lg border'>
|
||||||
{rows.map((row) => {
|
{rows.map((row) => {
|
||||||
@@ -319,7 +145,7 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
|||||||
key={key}
|
key={key}
|
||||||
className={cn('bg-card px-3 py-2.5', getRowClassName?.(row))}
|
className={cn('bg-card px-3 py-2.5', getRowClassName?.(row))}
|
||||||
>
|
>
|
||||||
<RowComponent row={row} />
|
<CardRowContent row={row} compact={hasCompactMeta} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -115,6 +115,12 @@ export type DataTableToolbarProps<TData> = {
|
|||||||
* Hide the View Options (column visibility) dropdown.
|
* Hide the View Options (column visibility) dropdown.
|
||||||
*/
|
*/
|
||||||
hideViewOptions?: boolean
|
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
|
* Content rendered on the LEFT side of the secondary action row. When
|
||||||
* provided the toolbar splits into two visual rows:
|
* provided the toolbar splits into two visual rows:
|
||||||
@@ -302,6 +308,8 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
|||||||
<DataTableViewOptions table={props.table} />
|
<DataTableViewOptions table={props.table} />
|
||||||
) : null
|
) : null
|
||||||
|
|
||||||
|
const viewToggleNode = props.viewToggle ?? null
|
||||||
|
|
||||||
const expandToggle = hasExpandable ? (
|
const expandToggle = hasExpandable ? (
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
@@ -350,6 +358,7 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
|||||||
{props.preActions}
|
{props.preActions}
|
||||||
{resetButton}
|
{resetButton}
|
||||||
{searchButton}
|
{searchButton}
|
||||||
|
{viewToggleNode}
|
||||||
{viewOptionsNode}
|
{viewOptionsNode}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -373,6 +382,7 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
|||||||
{props.preActions}
|
{props.preActions}
|
||||||
{resetButton}
|
{resetButton}
|
||||||
{searchButton}
|
{searchButton}
|
||||||
|
{viewToggleNode}
|
||||||
{viewOptionsNode}
|
{viewOptionsNode}
|
||||||
{expandToggle}
|
{expandToggle}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/*
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
} from '../lib'
|
||||||
import type { Channel, ChannelSortBy } from '../types'
|
import type { Channel, ChannelSortBy } from '../types'
|
||||||
import { useChannelsColumns } from './channels-columns'
|
import { useChannelsColumns } from './channels-columns'
|
||||||
|
import { ChannelCard } from './channel-card'
|
||||||
import { useChannels } from './channels-provider'
|
import { useChannels } from './channels-provider'
|
||||||
import { DataTableBulkActions } from './data-table-bulk-actions'
|
import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||||
|
|
||||||
const route = getRouteApi('/_authenticated/channels/')
|
const route = getRouteApi('/_authenticated/channels/')
|
||||||
const CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY =
|
const CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY =
|
||||||
'channels:column-visibility'
|
'channels:column-visibility'
|
||||||
|
const CHANNELS_VIEW_MODE_STORAGE_KEY = 'channels:view-mode'
|
||||||
|
|
||||||
const CHANNEL_SORTABLE_COLUMNS = new Set<ChannelSortBy>([
|
const CHANNEL_SORTABLE_COLUMNS = new Set<ChannelSortBy>([
|
||||||
'id',
|
'id',
|
||||||
@@ -355,6 +357,10 @@ export function ChannelsTable() {
|
|||||||
'No channels available. Create your first channel to get started.'
|
'No channels available. Create your first channel to get started.'
|
||||||
)}
|
)}
|
||||||
skeletonKeyPrefix='channel-skeleton'
|
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
|
applyHeaderSize
|
||||||
toolbarProps={{
|
toolbarProps={{
|
||||||
searchPlaceholder: t('Filter by name, ID, or key...'),
|
searchPlaceholder: t('Filter by name, ID, or key...'),
|
||||||
|
|||||||
Reference in New Issue
Block a user