diff --git a/web/default/src/components/data-table/hooks/use-data-table-view-mode.ts b/web/default/src/components/data-table/hooks/use-data-table-view-mode.ts new file mode 100644 index 00000000..d0afb554 --- /dev/null +++ b/web/default/src/components/data-table/hooks/use-data-table-view-mode.ts @@ -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 . + +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(() => + 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] +} diff --git a/web/default/src/components/data-table/index.ts b/web/default/src/components/data-table/index.ts index e4d6a89e..c79711ca 100644 --- a/web/default/src/components/data-table/index.ts +++ b/web/default/src/components/data-table/index.ts @@ -37,11 +37,27 @@ export { type DataTableRenderRowHelpers, } 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 = diff --git a/web/default/src/components/data-table/layout/card-cell-utils.ts b/web/default/src/components/data-table/layout/card-cell-utils.ts new file mode 100644 index 00000000..c5b0f5a2 --- /dev/null +++ b/web/default/src/components/data-table/layout/card-cell-utils.ts @@ -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 . + +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(cell: Cell): 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( + cell: Cell +): 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(table: Table): boolean { + return table.getVisibleLeafColumns().some((col) => { + const meta = col.columnDef.meta + return Boolean(meta?.mobileTitle || meta?.mobileBadge) + }) +} diff --git a/web/default/src/components/data-table/layout/card-grid.tsx b/web/default/src/components/data-table/layout/card-grid.tsx new file mode 100644 index 00000000..65ac40b0 --- /dev/null +++ b/web/default/src/components/data-table/layout/card-grid.tsx @@ -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 . + +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 { + table: Table + isLoading?: boolean + emptyTitle?: string + emptyDescription?: string + emptyIcon?: React.ReactNode + getRowKey?: (row: Row) => string | number + getRowClassName?: (row: Row) => string | undefined + /** + * Custom card renderer. When omitted, cards render generically from the + * column definitions via {@link CardRowContent} (driven by column meta). + */ + renderCard?: ( + row: Row, + 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 ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+ + +
+
+ {[1, 2, 3, 4].map((j) => ( +
+ + +
+ ))} +
+
+ ))} +
+ ) +} + +/** + * 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(props: DataTableCardGridProps) { + 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 ( + + ) + } + + const rows = props.table.getRowModel().rows + + if (!rows || rows.length === 0) { + return ( +
+ + + + {props.emptyIcon ?? } + + {resolvedEmptyTitle} + {resolvedEmptyDescription} + + +
+ ) + } + + return ( +
+ {rows.map((row) => { + const key = props.getRowKey ? props.getRowKey(row) : row.id + return ( +
+ {props.renderCard ? ( + props.renderCard(row, { compact }) + ) : ( + + )} +
+ ) + })} +
+ ) +} diff --git a/web/default/src/components/data-table/layout/card-row-content.tsx b/web/default/src/components/data-table/layout/card-row-content.tsx new file mode 100644 index 00000000..7eae1850 --- /dev/null +++ b/web/default/src/components/data-table/layout/card-row-content.tsx @@ -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 . + +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({ row }: { row: Row }) { + 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 */} +
+ {titleCell && ( +
+ {renderCellContent(titleCell)} +
+ )} + {badgeCell && ( +
+ {renderCellContent(badgeCell)} +
+ )} +
+ + {/* Row 2: Key fields wrap into compact columns instead of squeezing */} + {fieldCells.length > 0 && ( +
+ {fieldCells.map((cell) => { + const label = getCellLabel(cell) + return ( +
+ {label && ( +
+ {label} +
+ )} +
+ + {renderCellContent(cell) ?? '-'} + +
+
+ ) + })} +
+ )} + + {/* Actions */} + {actionsCell && ( +
+ {renderCellContent(actionsCell)} +
+ )} + + ) +} + +/** + * Fallback content — condensed label:value pairs for tables without + * mobileTitle/mobileBadge. Still respects mobileHidden. + */ +function FallbackContent({ row }: { row: Row }) { + 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 ( +
+ + {renderCellContent(cell)} + +
+ ) + } + + return ( +
+ + {label} + +
+ + {renderCellContent(cell) ?? '-'} + +
+
+ ) + })} + {actionsCell && ( +
+ {renderCellContent(actionsCell)} +
+ )} + + ) +} + +/** + * 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(props: { + row: Row + compact: boolean +}) { + return props.compact ? ( + + ) : ( + + ) +} diff --git a/web/default/src/components/data-table/layout/data-table-page.tsx b/web/default/src/components/data-table/layout/data-table-page.tsx index 4b6a20bb..7b1cfd2e 100644 --- a/web/default/src/components/data-table/layout/data-table-page.tsx +++ b/web/default/src/components/data-table/layout/data-table-page.tsx @@ -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 = { * 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 + >['renderCard'] + + /** + * Responsive grid className override for the card view. + */ + cardGridClassName?: string } /** @@ -236,9 +290,21 @@ export function DataTablePage(props: DataTablePageProps) { 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 ? ( + + ) : 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(props: DataTablePageProps) { } function renderToolbar( - props: DataTablePageProps + props: DataTablePageProps, + 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 + return ( + + ) } return null } @@ -323,13 +397,41 @@ function renderMobile( function renderDesktop( props: DataTablePageProps, - 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 ( +
+ + props.getRowClassName?.(row, { isMobile: false }) + } + /> +
+ ) + } + return ( . 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 { table: Table @@ -45,21 +41,6 @@ interface MobileCardListProps { getRowClassName?: (row: Row) => string | undefined } -function getCellLabel(cell: Cell): string | null { - const { header, meta } = cell.column.columnDef - if (typeof header === 'string') return header - if (meta?.label) return meta.label - return null -} - -function renderCellContent(cell: Cell): React.ReactNode { - const cellRenderer = cell.column.columnDef.cell - if (cellRenderer) { - return flexRender(cellRenderer, cell.getContext()) - } - return cell.getValue() as React.ReactNode -} - function ListSkeleton() { return (
@@ -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({ row }: { row: Row }) { - 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 */} -
- {titleCell && ( -
- {renderCellContent(titleCell)} -
- )} - {badgeCell && ( -
- {renderCellContent(badgeCell)} -
- )} -
- - {/* Row 2: Key fields wrap into compact columns instead of squeezing */} - {fieldCells.length > 0 && ( -
- {fieldCells.map((cell) => { - const label = getCellLabel(cell) - return ( -
- {label && ( -
- {label} -
- )} -
- - {renderCellContent(cell) ?? '-'} - -
-
- ) - })} -
- )} - - {/* Actions */} - {actionsCell && ( -
- {renderCellContent(actionsCell)} -
- )} - - ) -} - -/** - * Fallback list row — condensed label:value pairs for tables without - * mobileTitle/mobileBadge. Still respects mobileHidden. - */ -function FallbackRow({ row }: { row: Row }) { - 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 ( -
- - {renderCellContent(cell)} - -
- ) - } - - return ( -
- - {label} - -
- - {renderCellContent(cell) ?? '-'} - -
-
- ) - })} - {actionsCell && ( -
- {renderCellContent(actionsCell)} -
- )} - - ) -} - /** * 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(props: MobileCardListProps) { const { @@ -278,11 +109,8 @@ export function MobileCardList(props: MobileCardListProps) { 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(props: MobileCardListProps) { ) } - const RowComponent = hasCompactMeta ? CompactRow : FallbackRow - return (
{rows.map((row) => { @@ -319,7 +145,7 @@ export function MobileCardList(props: MobileCardListProps) { key={key} className={cn('bg-card px-3 py-2.5', getRowClassName?.(row))} > - +
) })} diff --git a/web/default/src/components/data-table/toolbar/toolbar.tsx b/web/default/src/components/data-table/toolbar/toolbar.tsx index 543e6146..cca09364 100644 --- a/web/default/src/components/data-table/toolbar/toolbar.tsx +++ b/web/default/src/components/data-table/toolbar/toolbar.tsx @@ -115,6 +115,12 @@ export type DataTableToolbarProps = { * Hide the View Options (column visibility) dropdown. */ 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: @@ -302,6 +308,8 @@ export function DataTableToolbar(props: DataTableToolbarProps) { ) : null + const viewToggleNode = props.viewToggle ?? null + const expandToggle = hasExpandable ? (
@@ -373,6 +382,7 @@ export function DataTableToolbar(props: DataTableToolbarProps) { {props.preActions} {resetButton} {searchButton} + {viewToggleNode} {viewOptionsNode} {expandToggle} diff --git a/web/default/src/components/data-table/toolbar/view-mode-toggle.tsx b/web/default/src/components/data-table/toolbar/view-mode-toggle.tsx new file mode 100644 index 00000000..7ab763fa --- /dev/null +++ b/web/default/src/components/data-table/toolbar/view-mode-toggle.tsx @@ -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 . + +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 ( +
+ {segments.map((segment) => { + const Icon = segment.icon + const isActive = segment.value === props.value + return ( + + 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' + )} + > + + + } + /> + + {segment.tooltip} + + + ) + })} +
+ ) +} diff --git a/web/default/src/features/channels/components/channel-card.tsx b/web/default/src/features/channels/components/channel-card.tsx new file mode 100644 index 00000000..21b47e21 --- /dev/null +++ b/web/default/src/features/channels/components/channel-card.tsx @@ -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 . + +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 }) { + 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 = { + 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 ( +
+ {/* Header: selection + name/remark, with status badge + actions menu */} +
+
+ {!isTagRow && selectCell && ( +
{selectCell}
+ )} +
{nameCell}
+
+
+ {statusCell} + {actionsCell} +
+
+ + {/* Body: labelled fields for every remaining column */} +
+ {FIELD_COLUMN_IDS.map((id) => { + const content = renderCell(id) + return ( +
+
+ {fieldLabels[id]} +
+
+ {content ?? -} +
+
+ ) + })} +
+
+ ) +} diff --git a/web/default/src/features/channels/components/channels-table.tsx b/web/default/src/features/channels/components/channels-table.tsx index 53f66d48..df2979e6 100644 --- a/web/default/src/features/channels/components/channels-table.tsx +++ b/web/default/src/features/channels/components/channels-table.tsx @@ -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([ '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) => } + 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...'),