feat(table): support card layout
This commit is contained in:
+75
-45
@@ -18,35 +18,27 @@ 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 { GroupBadge } from '@/components/group-badge'
|
||||
import { CHANNEL_STATUS } from '../constants'
|
||||
import { isTagAggregateRow, parseGroupsList } from '../lib'
|
||||
import type { Channel } from '../types'
|
||||
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Field columns rendered in the labelled grid (in display order). The first
|
||||
* row (`select`, `type`, `status`, `actions`), the channel name row
|
||||
* (`#id` label + `name`, aligned with `priority`/`weight`), and the
|
||||
* full-width `group` row are laid out separately around the grid.
|
||||
*/
|
||||
const FIELD_COLUMN_IDS = [
|
||||
'type',
|
||||
'id',
|
||||
'group',
|
||||
'balance',
|
||||
'priority',
|
||||
'weight',
|
||||
'response_time',
|
||||
'test_time',
|
||||
'tag',
|
||||
'models',
|
||||
] as const
|
||||
const FIELD_COLUMN_IDS = ['balance', 'response_time', 'test_time'] 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.
|
||||
* renderer via `flexRender`, so the table's information and interactions are
|
||||
* preserved: row selection, provider/multi-key/IO.NET type badge, id,
|
||||
* name/remark + warning icons, status (with tooltips), groups, 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()
|
||||
@@ -62,51 +54,76 @@ export function ChannelCard({ row }: { row: Row<Channel> }) {
|
||||
}
|
||||
|
||||
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 groups = parseGroupsList(row.original.group ?? '')
|
||||
|
||||
const selectCell = renderCell('select')
|
||||
const typeCell = renderCell('type')
|
||||
const nameCell = renderCell('name')
|
||||
const statusCell = renderCell('status')
|
||||
const actionsCell = renderCell('actions')
|
||||
const priorityCell = renderCell('priority')
|
||||
const weightCell = renderCell('weight')
|
||||
|
||||
// In card view the enable/disable state is already conveyed by the inline
|
||||
// power toggle, so the plain "Enabled"/"Disabled" badge is redundant. Keep
|
||||
// only the informative states (e.g. auto-disabled, unknown) and tag rows.
|
||||
const showStatusBadge =
|
||||
isTagRow ||
|
||||
(row.original.status !== CHANNEL_STATUS.ENABLED &&
|
||||
row.original.status !== CHANNEL_STATUS.MANUAL_DISABLED)
|
||||
|
||||
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'>
|
||||
{/* Row 1: selection + type, with status badge + actions menu */}
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
{!isTagRow && selectCell && (
|
||||
<div className='pt-0.5'>{selectCell}</div>
|
||||
<span className='flex-shrink-0'>{selectCell}</span>
|
||||
)}
|
||||
<div className='min-w-0 flex-1'>{nameCell}</div>
|
||||
<div className='min-w-0 overflow-hidden'>{typeCell}</div>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-1.5'>
|
||||
{statusCell}
|
||||
{actionsCell}
|
||||
{showStatusBadge && statusCell}
|
||||
<ChannelRowActionsLayoutContext.Provider value='card'>
|
||||
{actionsCell}
|
||||
</ChannelRowActionsLayoutContext.Provider>
|
||||
</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'>
|
||||
{/* Row 2: channel id + name (left) aligned with priority/weight (right) */}
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='min-w-0 flex-1 overflow-hidden text-sm'>
|
||||
{!isTagRow && (
|
||||
<div className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase select-none'>
|
||||
#{row.original.id}
|
||||
</div>
|
||||
)}
|
||||
{nameCell}
|
||||
</div>
|
||||
<div className='grid flex-shrink-0 grid-cols-2 items-center gap-x-3 text-center'>
|
||||
<span className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase select-none'>
|
||||
{t('Priority')}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase select-none'>
|
||||
{t('Weight')}
|
||||
</span>
|
||||
<div className='flex justify-center'>{priorityCell}</div>
|
||||
<div className='flex justify-center'>{weightCell}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body: labelled fields for the remaining columns. Three fields share a
|
||||
single row at every width (the card is wide enough even on phones). */}
|
||||
<div className='grid grid-cols-3 gap-x-4 gap-y-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 key={id} className='min-w-0'>
|
||||
<div className='text-muted-foreground mb-1 text-[11px] font-medium tracking-wide uppercase select-none'>
|
||||
{fieldLabels[id]}
|
||||
</div>
|
||||
@@ -117,6 +134,19 @@ export function ChannelCard({ row }: { row: Row<Channel> }) {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Last row: groups span the full width, showing every group (no label) */}
|
||||
<div className='min-w-0'>
|
||||
{groups.length > 0 ? (
|
||||
<div className='-ml-1.5 flex flex-wrap gap-1'>
|
||||
{groups.map((g) => (
|
||||
<GroupBadge key={g} group={g} size='sm' />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
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 { createContext } from 'react'
|
||||
|
||||
/**
|
||||
* Where the channel row actions are being rendered. In the card view we
|
||||
* surface the "Test Connection" action as an inline button next to the quick
|
||||
* test; in the table it stays inside the overflow menu.
|
||||
*/
|
||||
export type ChannelRowActionsLayout = 'table' | 'card'
|
||||
|
||||
export const ChannelRowActionsLayoutContext =
|
||||
createContext<ChannelRowActionsLayout>('table')
|
||||
@@ -30,7 +30,11 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { getCurrencyLabel } from '@/lib/currency'
|
||||
import {
|
||||
formatCurrencyFromUSD,
|
||||
formatQuotaWithCurrency,
|
||||
getCurrencyLabel,
|
||||
} from '@/lib/currency'
|
||||
import {
|
||||
formatTimestampToDate,
|
||||
formatQuota as formatQuotaValue,
|
||||
@@ -266,11 +270,17 @@ function WeightCell({ channel }: { channel: Channel }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline balance/used values longer than this switch to locale-aware compact
|
||||
* notation (e.g. "$28万"); the precise value stays available in the tooltip.
|
||||
*/
|
||||
const MAX_INLINE_BALANCE_CHARS = 8
|
||||
|
||||
/**
|
||||
* Balance cell component with click to update
|
||||
*/
|
||||
function BalanceCell({ channel }: { channel: Channel }) {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const isTagRow = isTagAggregateRow(channel)
|
||||
const balance = channel.balance || 0
|
||||
@@ -284,22 +294,43 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
const withSuffix = (value: string) =>
|
||||
tokenSuffix && value !== '-' ? `${value}${tokenSuffix}` : value
|
||||
|
||||
const usedDisplay = withSuffix(formatQuotaValue(usedQuota))
|
||||
const remainingDisplay = withSuffix(formatBalance(balance))
|
||||
const usedLabel = `${t('Used:')} ${usedDisplay}`
|
||||
const remainingLabel = `${t('Remaining:')} ${remainingDisplay}`
|
||||
const locale = i18n.resolvedLanguage || i18n.language
|
||||
// Precise values are kept for the tooltip; long values are shown compactly inline.
|
||||
const usedFull = withSuffix(formatQuotaValue(usedQuota))
|
||||
const remainingFull = withSuffix(formatBalance(balance))
|
||||
const usedDisplay =
|
||||
usedFull.length > MAX_INLINE_BALANCE_CHARS
|
||||
? withSuffix(formatQuotaWithCurrency(usedQuota, { compact: true, locale }))
|
||||
: usedFull
|
||||
const remainingDisplay =
|
||||
remainingFull.length > MAX_INLINE_BALANCE_CHARS
|
||||
? withSuffix(formatCurrencyFromUSD(balance, { compact: true, locale }))
|
||||
: remainingFull
|
||||
const usedLabel = `${t('Used:')} ${usedFull}`
|
||||
const remainingLabel = `${t('Remaining:')} ${remainingFull}`
|
||||
|
||||
// Tag row: only show cumulative used quota
|
||||
if (isTagRow) {
|
||||
return (
|
||||
<StatusBadge
|
||||
label={usedLabel}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<StatusBadge
|
||||
label={`${t('Used:')} ${usedDisplay}`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='-ml-1.5 cursor-help'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<p>{usedLabel}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -424,7 +455,8 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
* Generate channels columns configuration
|
||||
*/
|
||||
export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = i18n.resolvedLanguage || i18n.language
|
||||
return [
|
||||
// Checkbox column
|
||||
{
|
||||
@@ -645,8 +677,10 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
}
|
||||
>
|
||||
<ProviderBadge
|
||||
iconKey={iconName}
|
||||
iconKey={`${iconName}.Color`}
|
||||
iconSize={18}
|
||||
label={typeName}
|
||||
colorText={false}
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='max-w-full min-w-0 overflow-hidden'
|
||||
@@ -818,7 +852,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
variant={config.variant}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -970,7 +1003,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const timeText = formatRelativeTime(testTime)
|
||||
const timeText = formatRelativeTime(testTime, locale)
|
||||
const fullDate = formatTimestampToDate(testTime)
|
||||
|
||||
// For valid timestamps, show tooltip with full date
|
||||
|
||||
@@ -361,6 +361,7 @@ export function ChannelsTable() {
|
||||
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'
|
||||
mobileCardView
|
||||
applyHeaderSize
|
||||
toolbarProps={{
|
||||
searchPlaceholder: t('Filter by name, ID, or key...'),
|
||||
|
||||
@@ -16,14 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useContext, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { type Row } from '@tanstack/react-table'
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Boxes,
|
||||
Pencil,
|
||||
TestTube,
|
||||
PlugZap,
|
||||
Gauge,
|
||||
DollarSign,
|
||||
Download,
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
} from '../lib'
|
||||
import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils'
|
||||
import type { Channel } from '../types'
|
||||
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
|
||||
import { useChannels } from './channels-provider'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
@@ -70,6 +71,7 @@ interface DataTableRowActionsProps {
|
||||
|
||||
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const layout = useContext(ChannelRowActionsLayoutContext)
|
||||
const channel = row.original
|
||||
const { setOpen, setCurrentRow, upstream } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -166,6 +168,27 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
<TooltipContent>{t('Test Connection')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{layout === 'card' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleTest()
|
||||
}}
|
||||
aria-label={t('Test Channel Connection')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PlugZap className='size-4' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Test Channel Connection')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
@@ -178,7 +201,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
className={
|
||||
isEnabled
|
||||
? 'text-destructive hover:text-destructive'
|
||||
: 'text-emerald-600 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-400'
|
||||
: 'text-success hover:text-success'
|
||||
}
|
||||
/>
|
||||
}
|
||||
@@ -221,7 +244,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
<DropdownMenuItem onClick={handleTest}>
|
||||
{t('Test Connection')}
|
||||
<DropdownMenuShortcut>
|
||||
<TestTube size={16} />
|
||||
<PlugZap size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ export function NumericSpinnerInput({
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'group/spinner inline-flex h-7 items-center gap-0 rounded-md transition-colors',
|
||||
'group/spinner border-input inline-flex h-7 items-center gap-0 rounded-md border transition-colors',
|
||||
!disabled && 'hover:bg-muted/60',
|
||||
editing && 'bg-muted/60 ring-primary/30 ring-1'
|
||||
)}
|
||||
|
||||
+27
-5
@@ -17,7 +17,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { formatCurrencyFromUSD, formatQuotaWithCurrency } from '@/lib/currency'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import {
|
||||
CHANNEL_STATUS_CONFIG,
|
||||
@@ -361,14 +360,37 @@ export function getResponseTimeConfig(timeMs: number) {
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format Unix timestamp to relative time
|
||||
* e.g., "2 hours ago", "3 days ago"
|
||||
* Format a Unix timestamp (seconds) as a compact, locale-aware relative time.
|
||||
* Uses `Intl.RelativeTimeFormat` with the `narrow` style so the label stays
|
||||
* short inside table cells, e.g. "4h ago" / "42m ago" (en) or "4小时前" (zh),
|
||||
* instead of the verbose "4 hours ago".
|
||||
*/
|
||||
export function formatRelativeTime(timestamp: number): string {
|
||||
export function formatRelativeTime(
|
||||
timestamp: number,
|
||||
locale?: Intl.LocalesArgument
|
||||
): string {
|
||||
if (!timestamp || timestamp === 0) return 'Never'
|
||||
|
||||
try {
|
||||
return dayjs(timestamp * 1000).fromNow()
|
||||
const diffSec = timestamp - Date.now() / 1000
|
||||
const absSec = Math.abs(diffSec)
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, {
|
||||
numeric: 'always',
|
||||
style: 'narrow',
|
||||
})
|
||||
|
||||
const MINUTE = 60
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
const MONTH = 30 * DAY
|
||||
const YEAR = 365 * DAY
|
||||
|
||||
if (absSec < MINUTE) return rtf.format(Math.round(diffSec), 'second')
|
||||
if (absSec < HOUR) return rtf.format(Math.round(diffSec / MINUTE), 'minute')
|
||||
if (absSec < DAY) return rtf.format(Math.round(diffSec / HOUR), 'hour')
|
||||
if (absSec < MONTH) return rtf.format(Math.round(diffSec / DAY), 'day')
|
||||
if (absSec < YEAR) return rtf.format(Math.round(diffSec / MONTH), 'month')
|
||||
return rtf.format(Math.round(diffSec / YEAR), 'year')
|
||||
} catch {
|
||||
return 'Unknown'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user