Commit ea7cb0ba by CaIon

refactor(web): unify table cells and quota details

parent ebe4c368
/*
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 { useTranslation } from 'react-i18next'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { toIntlLocale } from '@/i18n/languages'
import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
interface TimestampCellProps {
timestamp: number
now?: number
format?: 'relative' | 'absolute'
locale?: string
justNowLabel: string
className?: string
}
export function TimestampCell(props: TimestampCellProps) {
if (!props.timestamp || props.timestamp === -1) {
return <span className='text-muted-foreground'>-</span>
}
const timestampMs = props.timestamp * 1000
const absoluteTime = formatTimestampToDate(props.timestamp)
if (props.format === 'absolute') {
return (
<time
dateTime={new Date(timestampMs).toISOString()}
className={cn('block whitespace-nowrap tabular-nums', props.className)}
>
{absoluteTime}
</time>
)
}
const now = props.now ?? Date.now()
const isJustNow = timestampMs <= now && now - timestampMs < 60_000
const relativeTime = isJustNow
? props.justNowLabel
: formatTimestampRelative(props.timestamp, 'seconds', props.locale)
const [relativePrefix, relativeNumber, relativeSuffix] = relativeTime.split(
/(\p{Number}+(?:[.,\u00a0\u202f]\p{Number}+)*)/u
)
return (
<Tooltip>
<TooltipTrigger
render={
<time
dateTime={new Date(timestampMs).toISOString()}
tabIndex={0}
className={cn('block truncate', props.className)}
/>
}
>
{relativePrefix}
{relativeNumber && (
<span className='tabular-nums'>{relativeNumber}</span>
)}
{relativeSuffix}
</TooltipTrigger>
<TooltipContent>
<span className='tabular-nums'>{absoluteTime}</span>
</TooltipContent>
</Tooltip>
)
}
export function ActivityTimeCell(props: {
createdAt: number
lastAt: number
lastLabel: string
lastClassName?: string
now?: number
format?: 'relative' | 'absolute'
layout?: 'rows' | 'columns'
}) {
const { t, i18n } = useTranslation()
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
return (
<div
data-table-text='secondary'
className={cn(
'grid min-w-0 gap-y-1 text-xs font-normal',
props.layout === 'columns'
? 'grid-flow-col grid-cols-2 grid-rows-[auto_1fr] items-start gap-x-3'
: 'grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2'
)}
>
<span className='text-muted-foreground'>{t('Created')}</span>
<TimestampCell
timestamp={props.createdAt}
now={props.now}
format={props.format}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
<span className='text-muted-foreground'>{props.lastLabel}</span>
<TimestampCell
timestamp={props.lastAt}
now={props.now}
format={props.format}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
props.lastClassName ?? 'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
</div>
)
}
...@@ -145,12 +145,9 @@ function SplitHeaderTableView<TData>({ ...@@ -145,12 +145,9 @@ function SplitHeaderTableView<TData>({
props.bodyContainerClassName props.bodyContainerClassName
)} )}
> >
<table <Table
data-slot='table' withContainer={false}
className={cn( className={props.tableClassName}
'w-full caption-bottom text-sm tabular-nums',
props.tableClassName
)}
style={tableSizing.style} style={tableSizing.style}
> >
{tableSizing.colgroup} {tableSizing.colgroup}
...@@ -162,7 +159,7 @@ function SplitHeaderTableView<TData>({ ...@@ -162,7 +159,7 @@ function SplitHeaderTableView<TData>({
getColumnClassName={getColumnClassName} getColumnClassName={getColumnClassName}
/> />
{renderTableBody(props, rows, colSpan, getColumnClassName)} {renderTableBody(props, rows, colSpan, getColumnClassName)}
</table> </Table>
</div> </div>
</div> </div>
) )
......
...@@ -42,8 +42,8 @@ export function GroupMultiplierBadge(props: { ...@@ -42,8 +42,8 @@ export function GroupMultiplierBadge(props: {
<Badge <Badge
variant='outline' variant='outline'
className={cn( className={cn(
'relative h-5 min-w-12 rounded-full px-1.5 py-0 text-xs leading-none font-medium shadow-none', 'relative h-5 min-w-12 rounded-full px-1.5 py-0 text-sm leading-none font-medium shadow-none',
!props.label && 'font-mono tabular-nums', !props.label && 'tabular-nums',
colorClassName, colorClassName,
props.className props.className
)} )}
...@@ -122,7 +122,7 @@ export function GroupBadge(props: GroupBadgeProps) { ...@@ -122,7 +122,7 @@ export function GroupBadge(props: GroupBadgeProps) {
return ( return (
<span <span
className={cn( className={cn(
'inline-flex max-w-full min-w-0 items-center gap-2 text-xs', 'inline-flex max-w-full min-w-0 items-center gap-2 text-sm',
containerClassName containerClassName
)} )}
> >
......
...@@ -16,6 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,6 +16,8 @@ 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 type { ComponentProps } from 'react'
import { CopyButton } from '@/components/copy-button' import { CopyButton } from '@/components/copy-button'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
...@@ -23,6 +25,21 @@ import { ...@@ -23,6 +25,21 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover' } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
export function MaskedValueTrigger(props: ComponentProps<typeof Button>) {
return (
<Button
variant='ghost'
size='sm'
{...props}
className={cn(
'text-muted-foreground h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono text-xs hover:bg-transparent aria-expanded:bg-transparent',
props.className
)}
/>
)
}
interface MaskedValueDisplayProps { interface MaskedValueDisplayProps {
/** 弹层内标题,如 "Full API Key" / "Full Code" */ /** 弹层内标题,如 "Full API Key" / "Full Code" */
...@@ -44,15 +61,7 @@ export function MaskedValueDisplay(props: MaskedValueDisplayProps) { ...@@ -44,15 +61,7 @@ export function MaskedValueDisplay(props: MaskedValueDisplayProps) {
return ( return (
<div className='flex max-w-full min-w-0 items-center'> <div className='flex max-w-full min-w-0 items-center'>
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger render={<MaskedValueTrigger />}>
render={
<Button
variant='ghost'
size='sm'
className='h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono hover:bg-transparent aria-expanded:bg-transparent'
/>
}
>
<span className='truncate'>{props.maskedValue}</span> <span className='truncate'>{props.maskedValue}</span>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
......
/*
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 { Fragment, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
import { cn } from '@/lib/utils'
type QuotaDetailsPopoverProps = {
title: string
triggerLabel: string
details: ReadonlyArray<{ label: string; value: string }>
description?: string
children: ReactNode
afterTrigger?: ReactNode
className?: string
triggerClassName?: string
}
export function QuotaDetailsPopover(props: QuotaDetailsPopoverProps) {
return (
<Popover>
<div className={cn('w-full min-w-0', props.className)}>
<PopoverTrigger
render={
<Button
variant='ghost'
aria-label={props.triggerLabel}
className={cn(
'h-auto w-full min-w-0 justify-start px-0 py-0.5 text-left font-normal hover:bg-transparent aria-expanded:bg-transparent',
props.triggerClassName
)}
/>
}
>
{props.children}
</PopoverTrigger>
{props.afterTrigger}
</div>
<PopoverContent
align='start'
className='w-72 max-w-[calc(100vw-2rem)] gap-3 p-3'
>
<PopoverTitle>{props.title}</PopoverTitle>
<dl className='grid grid-cols-[1fr_auto] gap-x-4 gap-y-2 text-sm tabular-nums'>
{props.details.map((detail) => (
<Fragment key={detail.label}>
<dt className='text-muted-foreground'>{detail.label}</dt>
<dd className='text-right break-all'>{detail.value}</dd>
</Fragment>
))}
</dl>
{props.description && (
<p className='text-muted-foreground text-xs leading-relaxed'>
{props.description}
</p>
)}
</PopoverContent>
</Popover>
)
}
...@@ -22,17 +22,31 @@ import * as React from 'react' ...@@ -22,17 +22,31 @@ import * as React from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
function Table({ className, ...props }: React.ComponentProps<'table'>) { function Table({
className,
withContainer = true,
...props
}: React.ComponentProps<'table'> & { withContainer?: boolean }) {
const table = (
<table
data-slot='table'
className={cn(
'w-full caption-bottom text-sm tabular-nums [font-family:var(--font-body)] [&_td]:text-sm [&_td]:font-medium [&_th]:text-sm [&_:is(th,td)_*]:[font-family:inherit] [&_:is(th,td)_*]:[font-size:inherit] [&_:is(th,td)_*]:[font-weight:inherit]',
'[&_[data-table-text=secondary]]:text-xs [&_[data-table-text=secondary]]:font-normal',
className
)}
{...props}
/>
)
if (!withContainer) return table
return ( return (
<div <div
data-slot='table-container' data-slot='table-container'
className='relative w-full overflow-x-auto overflow-y-hidden' className='relative w-full overflow-x-auto overflow-y-hidden'
> >
<table {table}
data-slot='table'
className={cn('w-full caption-bottom text-sm tabular-nums', className)}
{...props}
/>
</div> </div>
) )
} }
......
...@@ -113,7 +113,7 @@ describe('API key group table cell', () => { ...@@ -113,7 +113,7 @@ describe('API key group table cell', () => {
color, color,
border, border,
'rounded-full', 'rounded-full',
'font-mono', 'tabular-nums',
'h-5', 'h-5',
'min-w-12' 'min-w-12'
) )
......
...@@ -166,7 +166,7 @@ describe('API key group combobox Auto effect', () => { ...@@ -166,7 +166,7 @@ describe('API key group combobox Auto effect', () => {
'h-5', 'h-5',
'min-w-12', 'min-w-12',
'rounded-full', 'rounded-full',
'font-mono', 'tabular-nums',
'border-muted-foreground/30' 'border-muted-foreground/30'
) )
expect(defaultRatio).not.toHaveTextContent('Ratio') expect(defaultRatio).not.toHaveTextContent('Ratio')
......
...@@ -508,7 +508,7 @@ it('keeps mobile quota readable and opens complete model and IP restrictions by ...@@ -508,7 +508,7 @@ it('keeps mobile quota readable and opens complete model and IP restrictions by
'font-normal' 'font-normal'
) )
expect(within(quota).getByText('4,490.16')).toHaveClass( expect(within(quota).getByText('4,490.16')).toHaveClass(
'font-mono', 'tabular-nums',
'text-sm', 'text-sm',
'font-normal', 'font-normal',
'text-right' 'text-right'
......
...@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { QuotaDetailsPopover } from '@/components/quota-details-popover'
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { toIntlLocale } from '@/i18n/languages' import { toIntlLocale } from '@/i18n/languages'
import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency' import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency'
...@@ -78,78 +72,45 @@ export function ApiKeyQuotaCell(props: ApiKeyQuotaCellProps) { ...@@ -78,78 +72,45 @@ export function ApiKeyQuotaCell(props: ApiKeyQuotaCellProps) {
? `${t('Unlimited')}; ${usageDescription}` ? `${t('Unlimited')}; ${usageDescription}`
: `${remainingDescription}; ${usageDescription}` : `${remainingDescription}; ${usageDescription}`
const details = []
if (!props.apiKey.unlimited_quota) {
details.push({ label: t('Remaining'), value: formattedRemaining })
}
details.push({ label: t('Used amount'), value: formattedUsed })
if (!props.apiKey.unlimited_quota) {
details.push({ label: t('Current total quota'), value: formattedTotal })
}
if (hasProgress) {
details.push({
label: t('Remaining percentage'),
value: `${formattedPercentage}%`,
})
}
return ( return (
<Popover> <QuotaDetailsPopover
<div title={`${t('Quota')} (${quotaUnit})`}
className={cn( triggerLabel={
'w-full min-w-0', props.variant === 'card'
props.variant === 'card' ? 'space-y-2.5' : 'max-w-45 space-y-1.5' ? `${t('Quota')} (${quotaUnit}); ${triggerLabel}`
)} : triggerLabel
> }
<PopoverTrigger details={details}
render={ description={
<Button props.apiKey.unlimited_quota
variant='ghost' ? t(
aria-label={ 'This API key has no quota limit. Requests still require available wallet or subscription quota.'
props.variant === 'card' )
? `${t('Quota')} (${quotaUnit}); ${triggerLabel}` : t(
: triggerLabel 'Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.'
} )
className={cn( }
'h-auto w-full min-w-0 justify-start px-0 text-left font-normal hover:bg-transparent aria-expanded:bg-transparent', className={
props.variant === 'card' ? 'py-0' : 'py-0.5' props.variant === 'card' ? 'space-y-2.5' : 'max-w-45 space-y-1.5'
)} }
/> triggerClassName={props.variant === 'card' ? 'py-0' : undefined}
} afterTrigger={
> !props.apiKey.unlimited_quota && (
<span
data-slot='api-key-quota-values'
className={cn(
'grid w-full min-w-0 items-baseline gap-x-2 gap-y-1 text-xs',
props.variant === 'card'
? 'grid-cols-[auto_minmax(0,1fr)]'
: 'grid-cols-2'
)}
>
{props.variant === 'card' && (
<span className='text-muted-foreground'>
{t('Remaining')}
<span className='ml-1'>({quotaUnit})</span>
</span>
)}
<span
className={cn(
'min-w-0 truncate',
props.variant === 'card'
? 'text-right text-sm leading-5 font-normal'
: 'text-left font-medium',
!props.apiKey.unlimited_quota && 'font-mono tabular-nums',
!props.apiKey.unlimited_quota &&
remaining < 0 &&
'text-destructive',
remaining === 0 &&
!props.apiKey.unlimited_quota &&
'text-muted-foreground'
)}
>
{props.apiKey.unlimited_quota
? t('Unlimited')
: formattedRemaining}
</span>
{props.variant === 'card' && (
<span className='text-muted-foreground'>{t('Used amount')}</span>
)}
<span
className={cn(
'text-muted-foreground min-w-0 truncate text-right font-mono tabular-nums',
props.variant === 'card' && 'text-sm leading-5 font-normal'
)}
>
{formattedUsed}
</span>
</span>
</PopoverTrigger>
{!props.apiKey.unlimited_quota && (
<Progress <Progress
value={percentage} value={percentage}
aria-label={t('Remaining percentage')} aria-label={t('Remaining percentage')}
...@@ -158,55 +119,53 @@ export function ApiKeyQuotaCell(props: ApiKeyQuotaCellProps) { ...@@ -158,55 +119,53 @@ export function ApiKeyQuotaCell(props: ApiKeyQuotaCellProps) {
progressColor progressColor
)} )}
/> />
)
}
>
<span
data-slot='api-key-quota-values'
className={cn(
'grid w-full min-w-0 items-baseline gap-x-2 gap-y-1 text-sm',
props.variant === 'card'
? 'grid-cols-[auto_minmax(0,1fr)]'
: 'grid-cols-2'
)} )}
</div>
<PopoverContent
align='start'
className='w-72 max-w-[calc(100vw-2rem)] gap-3 p-3'
> >
<PopoverTitle> {props.variant === 'card' && (
{t('Quota')} ({quotaUnit}) <span className='text-muted-foreground'>
</PopoverTitle> {t('Remaining')}
<dl className='grid grid-cols-[1fr_auto] gap-x-4 gap-y-2 text-sm tabular-nums'> <span className='ml-1'>({quotaUnit})</span>
{!props.apiKey.unlimited_quota && ( </span>
<> )}
<dt className='text-muted-foreground'>{t('Remaining')}</dt> <span
<dd className='text-right font-mono break-all'> className={cn(
{formattedRemaining} 'min-w-0 truncate',
</dd> props.variant === 'card'
</> ? 'text-right text-sm leading-5 font-normal'
)} : 'text-left font-medium',
<dt className='text-muted-foreground'>{t('Used amount')}</dt> !props.apiKey.unlimited_quota && 'tabular-nums',
<dd className='text-right font-mono break-all'>{formattedUsed}</dd> !props.apiKey.unlimited_quota &&
{!props.apiKey.unlimited_quota && ( remaining < 0 &&
<> 'text-destructive',
<dt className='text-muted-foreground'> remaining === 0 &&
{t('Current total quota')} !props.apiKey.unlimited_quota &&
</dt> 'text-muted-foreground'
<dd className='text-right font-mono break-all'>
{formattedTotal}
</dd>
</>
)} )}
{hasProgress && ( >
<> {props.apiKey.unlimited_quota ? t('Unlimited') : formattedRemaining}
<dt className='text-muted-foreground'> </span>
{t('Remaining percentage')} {props.variant === 'card' && (
</dt> <span className='text-muted-foreground'>{t('Used amount')}</span>
<dd className='text-right font-mono'>{formattedPercentage}%</dd> )}
</> <span
className={cn(
'text-muted-foreground min-w-0 truncate text-right tabular-nums',
props.variant === 'card' && 'text-sm leading-5 font-normal'
)} )}
</dl> >
<p className='text-muted-foreground text-xs leading-relaxed'> {formattedUsed}
{props.apiKey.unlimited_quota </span>
? t( </span>
'This API key has no quota limit. Requests still require available wallet or subscription quota.' </QuotaDetailsPopover>
)
: t(
'Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.'
)}
</p>
</PopoverContent>
</Popover>
) )
} }
...@@ -18,108 +18,32 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,108 +18,32 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import { ActivityTimeCell } from '@/components/activity-time-cell'
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { toIntlLocale } from '@/i18n/languages'
import dayjs from '@/lib/dayjs' import dayjs from '@/lib/dayjs'
import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import type { ApiKey } from '../types' import type { ApiKey } from '../types'
interface ApiKeyTimestampCellProps { export { TimestampCell as ApiKeyTimestampCell } from '@/components/activity-time-cell'
timestamp: number
now: number
locale?: string
justNowLabel: string
className?: string
}
export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) {
if (!props.timestamp || props.timestamp === -1) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const timestampMs = props.timestamp * 1000
const isJustNow = timestampMs <= props.now && props.now - timestampMs < 60_000
const relativeTime = isJustNow
? props.justNowLabel
: formatTimestampRelative(props.timestamp, 'seconds', props.locale)
const [relativePrefix, relativeNumber, relativeSuffix] = relativeTime.split(
/(\p{Number}+(?:[.,\u00a0\u202f]\p{Number}+)*)/u
)
const absoluteTime = formatTimestampToDate(props.timestamp)
return (
<Tooltip>
<TooltipTrigger
render={
<time
dateTime={new Date(timestampMs).toISOString()}
tabIndex={0}
className={cn('block truncate text-xs', props.className)}
/>
}
>
{relativePrefix}
{relativeNumber && (
<span className='font-mono tabular-nums'>{relativeNumber}</span>
)}
{relativeSuffix}
</TooltipTrigger>
<TooltipContent>
<span className='font-mono tabular-nums'>{absoluteTime}</span>
</TooltipContent>
</Tooltip>
)
}
export function ApiKeyActivityCell(props: { export function ApiKeyActivityCell(props: {
apiKey: ApiKey apiKey: ApiKey
now: number now: number
layout?: 'rows' | 'columns' layout?: 'rows' | 'columns'
}) { }) {
const { t, i18n } = useTranslation() const { t } = useTranslation()
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const accessedTime = props.apiKey.accessed_time const accessedTime = props.apiKey.accessed_time
const isStale = const isStale =
accessedTime > 0 && accessedTime > 0 &&
accessedTime * 1000 < dayjs(props.now).subtract(3, 'month').valueOf() accessedTime * 1000 < dayjs(props.now).subtract(3, 'month').valueOf()
return ( return (
<div <ActivityTimeCell
className={cn( createdAt={props.apiKey.created_time}
'grid min-w-0 gap-y-1 text-xs', lastAt={accessedTime}
props.layout === 'columns' lastLabel={t('Last Used')}
? 'grid-flow-col grid-cols-2 grid-rows-[auto_1fr] items-start gap-x-3' lastClassName={isStale ? 'text-warning' : 'text-muted-foreground'}
: 'grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2' now={props.now}
)} layout={props.layout}
> />
<span className='text-muted-foreground'>{t('Created')}</span>
<ApiKeyTimestampCell
timestamp={props.apiKey.created_time}
now={props.now}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
<span className='text-muted-foreground'>{t('Last Used')}</span>
<ApiKeyTimestampCell
timestamp={accessedTime}
now={props.now}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
isStale ? 'text-warning' : 'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
</div>
) )
} }
...@@ -21,6 +21,7 @@ import { useState, useCallback } from 'react' ...@@ -21,6 +21,7 @@ import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BadgeCell } from '@/components/data-table' import { BadgeCell } from '@/components/data-table'
import { MaskedValueTrigger } from '@/components/masked-value-display'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
...@@ -86,15 +87,7 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) { ...@@ -86,15 +87,7 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
return ( return (
<div className='flex max-w-full min-w-0 items-center'> <div className='flex max-w-full min-w-0 items-center'>
<Popover open={popoverOpen} onOpenChange={handlePopoverOpen}> <Popover open={popoverOpen} onOpenChange={handlePopoverOpen}>
<PopoverTrigger <PopoverTrigger render={<MaskedValueTrigger />}>
render={
<Button
variant='ghost'
size='sm'
className='text-muted-foreground h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono text-xs hover:bg-transparent aria-expanded:bg-transparent'
/>
}
>
<span className='truncate'>{maskedKey}</span> <span className='truncate'>{maskedKey}</span>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
......
...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,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 { type ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { MaskedValueDisplay } from '@/components/masked-value-display' import { MaskedValueDisplay } from '@/components/masked-value-display'
...@@ -32,7 +32,7 @@ import { formatQuota, formatTimestampToDate } from '@/lib/format' ...@@ -32,7 +32,7 @@ import { formatQuota, formatTimestampToDate } from '@/lib/format'
import { REDEMPTION_FILTER_EXPIRED, REDEMPTION_STATUSES } from '../constants' import { REDEMPTION_FILTER_EXPIRED, REDEMPTION_STATUSES } from '../constants'
import { isRedemptionExpired, isTimestampExpired } from '../lib' import { isRedemptionExpired, isTimestampExpired } from '../lib'
import { type Redemption } from '../types' import type { Redemption } from '../types'
import { DataTableRowActions } from './data-table-row-actions' import { DataTableRowActions } from './data-table-row-actions'
export function useRedemptionsColumns(): ColumnDef<Redemption>[] { export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
...@@ -135,7 +135,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] { ...@@ -135,7 +135,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
{ {
id: 'code', id: 'code',
accessorKey: 'key', accessorKey: 'key',
header: t('Code'), header: t('Redemption Code'),
cell: function CodeCell({ row }) { cell: function CodeCell({ row }) {
const redemption = row.original const redemption = row.original
const key = redemption.key const key = redemption.key
......
...@@ -18,8 +18,9 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,8 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { QuotaDetailsPopover } from '@/components/quota-details-popover'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { formatQuotaWithCurrency } from '@/lib/currency' import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSystemConfigStore } from '@/stores/system-config-store' import { useSystemConfigStore } from '@/stores/system-config-store'
...@@ -32,34 +33,55 @@ export function UserQuotaCell(props: UserQuotaCellProps) { ...@@ -32,34 +33,55 @@ export function UserQuotaCell(props: UserQuotaCellProps) {
const { t } = useTranslation() const { t } = useTranslation()
useSystemConfigStore((state) => state.config.currency) useSystemConfigStore((state) => state.config.currency)
if (props.remaining === 0 && props.used === 0) { const { meta: currency } = getCurrencyDisplay()
return ( const quotaUnit = currency.kind === 'tokens' ? t('Tokens') : currency.symbol
<StatusBadge const hasQuota = props.remaining !== 0 || props.used !== 0
label={t('No Quota')} const formattedRemaining = formatQuotaWithCurrency(props.remaining, {
variant='neutral' showSymbol: false,
copyable={false} })
className='-ml-1.5' const formattedUsed = formatQuotaWithCurrency(props.used, {
/> showSymbol: false,
) })
}
return ( return (
<div className='min-w-0 space-y-1 text-left tabular-nums'> <QuotaDetailsPopover
<div title={`${t('Quota')} (${quotaUnit})`}
className={cn( triggerLabel={
'font-mono text-sm font-semibold whitespace-nowrap', hasQuota
props.remaining < 0 && 'text-destructive', ? `${t('Available Balance')} ${formattedRemaining}; ${t('Used amount')} ${formattedUsed}`
props.remaining === 0 && 'text-muted-foreground' : t('No Quota')
)} }
> details={[
{formatQuotaWithCurrency(props.remaining, { showSymbol: false })} { label: t('Available Balance'), value: formattedRemaining },
</div> { label: t('Total Used'), value: formattedUsed },
<div className='text-muted-foreground flex items-baseline gap-1 text-xs whitespace-nowrap'> ]}
<span>{t('Used amount')}</span> >
<span className='font-mono'> {hasQuota ? (
{formatQuotaWithCurrency(props.used, { showSymbol: false })} <span className='grid min-w-0 grid-cols-1 gap-y-1 text-sm tabular-nums'>
<span
className={cn(
props.remaining < 0 && 'text-destructive',
props.remaining === 0 && 'text-muted-foreground'
)}
>
{formattedRemaining}
</span>
<span
data-table-text='secondary'
className='text-muted-foreground flex items-baseline gap-1 text-xs font-normal'
>
<span>{t('Used amount')}</span>
<span>{formattedUsed}</span>
</span>
</span> </span>
</div> ) : (
</div> <StatusBadge
label={t('No Quota')}
variant='neutral'
copyable={false}
className='-ml-1.5 font-normal'
/>
)}
</QuotaDetailsPopover>
) )
} }
...@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import type { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ActivityTimeCell } from '@/components/activity-time-cell'
import { BadgeCell } from '@/components/data-table' import { BadgeCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge' import { GroupBadge } from '@/components/group-badge'
import { LongText } from '@/components/long-text' import { LongText } from '@/components/long-text'
...@@ -31,7 +32,7 @@ import { ...@@ -31,7 +32,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { getCurrencyDisplay } from '@/lib/currency' import { getCurrencyDisplay } from '@/lib/currency'
import { formatQuota, formatTimestamp } from '@/lib/format' import { formatQuota } from '@/lib/format'
import { useSystemConfigStore } from '@/stores/system-config-store' import { useSystemConfigStore } from '@/stores/system-config-store'
import { import {
...@@ -80,7 +81,7 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -80,7 +81,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
return ( return (
<TableId <TableId
value={row.getValue('id') as number} value={row.getValue('id') as number}
className='w-[60px] text-sm' className='w-[60px] [font-family:inherit] text-sm'
/> />
) )
}, },
...@@ -98,13 +99,19 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -98,13 +99,19 @@ export function useUsersColumns(): ColumnDef<User>[] {
return ( return (
<div className='flex min-w-[160px] flex-col gap-1'> <div className='flex min-w-[160px] flex-col gap-1'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<LongText className='max-w-[140px] font-medium'> <LongText className='max-w-[140px] text-sm font-normal'>
{username} {username}
</LongText> </LongText>
{remark && ( {remark && (
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={<StatusBadge variant='success' copyable={false} />} render={
<StatusBadge
variant='success'
copyable={false}
className='font-normal'
/>
}
> >
<LongText className='max-w-[80px]'>{remark}</LongText> <LongText className='max-w-[80px]'>{remark}</LongText>
</TooltipTrigger> </TooltipTrigger>
...@@ -115,9 +122,12 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -115,9 +122,12 @@ export function useUsersColumns(): ColumnDef<User>[] {
)} )}
</div> </div>
{displayName && displayName !== username && ( {displayName && displayName !== username && (
<LongText className='text-muted-foreground max-w-[180px] text-xs'> <div
{displayName} data-table-text='secondary'
</LongText> className='text-muted-foreground max-w-[180px] text-xs font-normal'
>
<LongText>{displayName}</LongText>
</div>
)} )}
</div> </div>
) )
...@@ -148,6 +158,7 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -148,6 +158,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
label={t(statusConfig.labelKey)} label={t(statusConfig.labelKey)}
variant={isUserDeleted(user) ? 'neutral' : statusConfig.variant} variant={isUserDeleted(user) ? 'neutral' : statusConfig.variant}
copyable={false} copyable={false}
className='font-normal'
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
...@@ -184,7 +195,7 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -184,7 +195,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
const group = row.getValue('group') as string const group = row.getValue('group') as string
return ( return (
<BadgeCell> <BadgeCell>
<GroupBadge group={group} /> <GroupBadge group={group} className='font-normal' />
</BadgeCell> </BadgeCell>
) )
}, },
...@@ -230,7 +241,10 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -230,7 +241,10 @@ export function useUsersColumns(): ColumnDef<User>[] {
} }
return ( return (
<div className='min-w-0 space-y-1 text-xs'> <div
data-table-text='secondary'
className='min-w-0 space-y-1 text-xs font-normal'
>
{(affCount > 0 || affHistoryQuota !== 0) && ( {(affCount > 0 || affHistoryQuota !== 0) && (
<LongText> <LongText>
{t('Invited {{count}} users', { count: affCount })} ·{' '} {t('Invited {{count}} users', { count: affCount })} ·{' '}
...@@ -254,30 +268,17 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -254,30 +268,17 @@ export function useUsersColumns(): ColumnDef<User>[] {
}, },
{ {
accessorKey: 'created_at', accessorKey: 'created_at',
header: t('Created At'), header: t('Time'),
cell: ({ row }) => { cell: ({ row }) => (
const ts = row.getValue('created_at') as number | undefined <ActivityTimeCell
return ( createdAt={row.original.created_at ?? 0}
<span className='text-muted-foreground text-sm'> lastAt={row.original.last_login_at ?? 0}
{ts ? formatTimestamp(ts) : '-'} lastLabel={t('Last Login')}
</span> format='absolute'
) />
}, ),
size: 180, size: 260,
meta: { mobileHidden: true }, minSize: 240,
},
{
accessorKey: 'last_login_at',
header: t('Last Login'),
cell: ({ row }) => {
const ts = row.getValue('last_login_at') as number | undefined
return (
<span className='text-muted-foreground text-sm'>
{ts ? formatTimestamp(ts) : '-'}
</span>
)
},
size: 180,
meta: { mobileHidden: true }, meta: { mobileHidden: true },
}, },
{ {
......
...@@ -174,7 +174,6 @@ export function UsersTable() { ...@@ -174,7 +174,6 @@ export function UsersTable() {
data: users, data: users,
columns, columns,
enableRowSelection: true, enableRowSelection: true,
initialColumnVisibility: { created_at: false, last_login_at: false },
columnFilters, columnFilters,
globalFilter, globalFilter,
pagination, pagination,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment