fix(web): densify table rows and soften user avatars

Keep truncated cells single-line across data tables, compact oversized quota figures, and replace loud avatar gradients with identity-tint styling that matches badges.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
t0ng7u
2026-07-11 19:36:32 +08:00
co-authored by Cursor
parent 2a9e35c93b
commit c20682f66e
11 changed files with 149 additions and 89 deletions
@@ -59,8 +59,10 @@ function DataTableRowInner<TData>({
'max-w-full min-w-0',
contentMode === 'full' &&
'max-w-none overflow-visible [&_.truncate]:overflow-visible [&_.truncate]:text-clip',
// Keep `.truncate` children single-line (ellipsis + tooltip)
// even in wrap mode, so table rows stay at most ~2 lines tall.
contentMode === 'wrap' &&
'whitespace-normal break-words [overflow-wrap:anywhere] [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal',
'whitespace-normal break-words [overflow-wrap:anywhere]',
contentMode === 'summary' &&
'whitespace-normal break-words [overflow-wrap:anywhere]',
getColumnClassName?.(cell.column.id, 'cell')
+8 -8
View File
@@ -34,11 +34,11 @@ import {
import useDialogState from '@/hooks/use-dialog'
import { useIsSidebarModuleVisible } from '@/hooks/use-sidebar-config'
import { useUserDisplay } from '@/hooks/use-user-display'
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { getUserAvatarFallback, getUserAvatarProps } from '@/lib/avatar'
import { ROLE } from '@/lib/roles'
import { useAuthStore } from '@/stores/auth-store'
const avatarFallbackClassName = 'font-semibold text-white'
const avatarFallbackClassName = 'font-semibold'
export function ProfileDropdown() {
const { t } = useTranslation()
@@ -50,8 +50,8 @@ export function ProfileDropdown() {
const isWalletVisible = useIsSidebarModuleVisible('/wallet')
const avatarName = user?.username || displayName
const avatarFallback = getUserAvatarFallback(avatarName)
const avatarFallbackStyle = useMemo(
() => getUserAvatarStyle(avatarName),
const avatarProps = useMemo(
() => getUserAvatarProps(avatarName),
[avatarName]
)
@@ -63,8 +63,8 @@ export function ProfileDropdown() {
>
<Avatar className='size-6'>
<AvatarFallback
className={`${avatarFallbackClassName} text-xs`}
style={avatarFallbackStyle}
className={`${avatarFallbackClassName} ${avatarProps.className} text-xs`}
style={avatarProps.style}
>
{avatarFallback}
</AvatarFallback>
@@ -74,8 +74,8 @@ export function ProfileDropdown() {
<div className='flex items-center gap-2 px-1.5 py-1.5'>
<Avatar className='size-8'>
<AvatarFallback
className={`${avatarFallbackClassName} text-xs`}
style={avatarFallbackStyle}
className={`${avatarFallbackClassName} ${avatarProps.className} text-xs`}
style={avatarProps.style}
>
{avatarFallback}
</AvatarFallback>
@@ -585,7 +585,9 @@ export function useChannelsColumns(
meta: {
cardRole: 'title',
cardSpan: 2,
contentMode: 'wrap',
// 'summary' keeps the name on a single truncated line (full name in
// tooltip), matching the Groups column instead of wrapping rows tall.
contentMode: 'summary',
},
cell: ({ row }) => {
const isTagRow = isTagAggregateRow(row.original)
@@ -671,7 +673,7 @@ export function useChannelsColumns(
<Tooltip>
<TooltipTrigger
render={
<span className='text-muted-foreground text-xs' />
<span className='text-muted-foreground max-w-full truncate text-xs' />
}
>
{truncateText(channel.remark, 40)}
@@ -30,6 +30,8 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useGroupRatios } from '@/hooks/use-group-ratios'
import { toIntlLocale } from '@/i18n/languages'
import { formatQuotaWithCurrency } from '@/lib/currency'
import { formatQuota, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
@@ -42,6 +44,12 @@ import {
} from './api-keys-cells'
import { DataTableRowActions } from './data-table-row-actions'
/**
* Inline quota values longer than this switch to locale-aware compact
* notation (e.g. "¥4.8万"); precise values stay available in the tooltip.
*/
const MAX_INLINE_QUOTA_CHARS = 8
function getQuotaProgressColor(percentage: number): string {
if (percentage <= 10) {
return '[&_[data-slot=progress-indicator]]:bg-destructive'
@@ -51,8 +59,9 @@ function getQuotaProgressColor(percentage: number): string {
}
export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const groupRatios = useGroupRatios()
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
return [
{
id: 'select',
@@ -81,9 +90,14 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
{
accessorKey: 'name',
header: t('Name'),
cell: ({ row }) => (
<span className='font-medium'>{row.getValue('name')}</span>
),
cell: ({ row }) => {
const name = row.getValue('name') as string
return (
<span className='block max-w-full truncate font-medium' title={name}>
{name}
</span>
)
},
size: 180,
meta: {
cardRole: 'title',
@@ -140,15 +154,23 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
const total = used + remaining
const percentage = total > 0 ? (remaining / total) * 100 : 0
const toInlineQuota = (value: number) => {
const full = formatQuota(value)
if (full.length <= MAX_INLINE_QUOTA_CHARS) {
return full
}
return formatQuotaWithCurrency(value, { compact: true, locale })
}
return (
<Tooltip>
<TooltipTrigger render={<div className='w-[150px] space-y-1' />}>
<div className='flex justify-between text-xs'>
<span className='font-medium tabular-nums'>
{formatQuota(remaining)}
<div className='flex justify-between gap-2 text-xs'>
<span className='truncate font-medium tabular-nums'>
{toInlineQuota(remaining)}
</span>
<span className='text-muted-foreground tabular-nums'>
{formatQuota(total)}
<span className='text-muted-foreground truncate tabular-nums'>
{toInlineQuota(total)}
</span>
</div>
<Progress
@@ -22,7 +22,7 @@ import { CopyableStatusBadge, StatusBadge } from '@/components/status-badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { getUserAvatarFallback, getUserAvatarProps } from '@/lib/avatar'
import { formatCompactNumber, formatQuota } from '@/lib/format'
import { getRoleLabel } from '@/lib/roles'
@@ -68,7 +68,7 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
const displayName = getDisplayName(profile)
const avatarName = profile.username || displayName
const avatarFallback = getUserAvatarFallback(avatarName)
const avatarFallbackStyle = getUserAvatarStyle(avatarName)
const avatarProps = getUserAvatarProps(avatarName)
const roleLabel = getRoleLabel(profile.role)
const stats = [
{
@@ -91,8 +91,8 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
<div className='flex items-center gap-3 sm:gap-4'>
<Avatar className='size-12 rounded-xl text-base sm:size-14 sm:text-lg'>
<AvatarFallback
className='rounded-xl font-semibold text-white'
style={avatarFallbackStyle}
className={`rounded-xl font-semibold ${avatarProps.className}`}
style={avatarProps.style}
>
{avatarFallback}
</AvatarFallback>
@@ -85,9 +85,14 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
cardSpan: 2,
contentMode: 'wrap',
},
cell: ({ row }) => (
<span className='font-medium'>{row.getValue('name')}</span>
),
cell: ({ row }) => {
const name = row.getValue('name') as string
return (
<span className='block max-w-full truncate font-medium' title={name}>
{name}
</span>
)
},
size: 180,
},
{
@@ -61,9 +61,14 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
const plan = row.original.plan
return (
<div className='max-w-full min-w-0'>
<div className='font-medium'>{plan.title}</div>
<div className='truncate font-medium' title={plan.title}>
{plan.title}
</div>
{plan.subtitle && (
<div className='text-muted-foreground text-xs'>
<div
className='text-muted-foreground truncate text-xs'
title={plan.subtitle}
>
{plan.subtitle}
</div>
)}
@@ -35,7 +35,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useGroupRatios } from '@/hooks/use-group-ratios'
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { getUserAvatarFallback, getUserAvatarProps } from '@/lib/avatar'
import { getIdentityTextColorClass } from '@/lib/colors'
import { formatBillingCurrencyFromUSD } from '@/lib/currency'
import {
@@ -505,6 +505,10 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
if (!log.username) return null
const avatarProps = sensitiveVisible
? getUserAvatarProps(log.username)
: undefined
return (
<button
type='button'
@@ -515,17 +519,13 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
setUserInfoDialogOpen(true)
}}
>
<Avatar className='ring-border/60 size-6 ring-1 max-sm:hidden'>
<Avatar className='size-6 max-sm:hidden'>
<AvatarFallback
className={cn(
'text-xs font-semibold',
!sensitiveVisible && 'bg-muted text-muted-foreground'
avatarProps?.className
)}
style={
sensitiveVisible
? getUserAvatarStyle(log.username)
: undefined
}
style={avatarProps?.style}
>
{sensitiveVisible ? getUserAvatarFallback(log.username) : '•'}
</AvatarFallback>
@@ -609,7 +609,10 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</Tooltip>
</TooltipProvider>
{metaParts.length > 0 && (
<span className='text-subtle-foreground text-xs [overflow-wrap:anywhere] break-words'>
<span
className='text-subtle-foreground max-w-[150px] truncate text-xs'
title={sensitiveVisible ? metaParts.join(' · ') : undefined}
>
{metaParts.join(' · ')}
</span>
)}
@@ -621,7 +624,9 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
cardRole: 'primary',
cardOrder: 40,
cardSpan: 2,
contentMode: 'full',
// 'summary' (not 'full') so the group/ratio meta line can truncate
// instead of overflowing or wrapping to extra rows.
contentMode: 'summary',
},
})
columns.push(
@@ -23,7 +23,7 @@ import { useTranslation } from 'react-i18next'
import { CopyableStatusBadge, StatusBadge } from '@/components/status-badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { getUserAvatarFallback, getUserAvatarProps } from '@/lib/avatar'
import { formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
@@ -85,6 +85,9 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
useUsageLogsContext()
const log = row.original
const displayName = log.username || String(log.user_id || '?')
const avatarProps = sensitiveVisible
? getUserAvatarProps(displayName)
: undefined
return (
<button
@@ -96,20 +99,18 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
setUserInfoDialogOpen(true)
}}
>
<Avatar className='ring-border/60 size-6 ring-1 max-sm:hidden'>
<Avatar className='size-6 max-sm:hidden'>
<AvatarFallback
className={cn(
'text-xs font-semibold',
!sensitiveVisible && 'bg-muted text-muted-foreground'
)}
style={
sensitiveVisible ? getUserAvatarStyle(displayName) : undefined
}
className={cn('text-xs font-semibold', avatarProps?.className)}
style={avatarProps?.style}
>
{sensitiveVisible ? getUserAvatarFallback(displayName) : '•'}
</AvatarFallback>
</Avatar>
<span className='text-muted-foreground text-sm [overflow-wrap:anywhere] break-words hover:underline'>
<span
className='text-muted-foreground max-w-[100px] truncate text-sm hover:underline'
title={sensitiveVisible ? displayName : undefined}
>
{sensitiveVisible ? displayName : '••••'}
</span>
</button>
@@ -143,7 +144,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
>
{taskId}
</CopyableStatusBadge>
<span className='text-subtle-foreground text-xs [overflow-wrap:anywhere] break-words'>
<span className='text-subtle-foreground max-w-[200px] truncate text-xs'>
{getTaskPlatformName(log.platform)} ·{' '}
{t(taskActionMapper.getLabel(log.action))}
</span>
+36 -9
View File
@@ -31,6 +31,8 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { toIntlLocale } from '@/i18n/languages'
import { formatQuotaWithCurrency } from '@/lib/currency'
import { formatQuota, formatTimestamp } from '@/lib/format'
import { cn } from '@/lib/utils'
@@ -43,6 +45,12 @@ import {
import type { User } from '../types'
import { DataTableRowActions } from './data-table-row-actions'
/**
* Inline quota values longer than this switch to locale-aware compact
* notation (e.g. "¥4.8万"); precise values stay available in the tooltip.
*/
const MAX_INLINE_QUOTA_CHARS = 8
function getQuotaProgressColor(percentage: number): string {
if (percentage <= 10) {
return '[&_[data-slot=progress-indicator]]:bg-destructive'
@@ -54,7 +62,8 @@ function getQuotaProgressColor(percentage: number): string {
}
export function useUsersColumns(): ColumnDef<User>[] {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
return [
{
id: 'select',
@@ -108,13 +117,23 @@ export function useUsersColumns(): ColumnDef<User>[] {
return (
<div className='flex min-w-0 flex-col gap-1 sm:min-w-[160px]'>
<div className='flex flex-wrap items-center gap-2'>
<LongText className='max-w-full font-medium sm:max-w-[140px]'>
<div className='flex min-w-0 items-center gap-2'>
<LongText className='max-w-full shrink-0 font-medium sm:max-w-[140px]'>
{username}
</LongText>
{remark && (
<Tooltip>
<TooltipTrigger render={<StatusBadge variant='success' />}>
<TooltipTrigger
render={
<StatusBadge
variant='success'
// Keep the remark on the username line: allow the badge
// to shrink and ellipsize instead of wrapping to a new
// row (full text stays in the tooltip).
className='min-w-0 shrink overflow-hidden [&_[data-slot=status-badge-label]]:max-w-full [&_[data-slot=status-badge-label]]:min-w-0 [&_[data-slot=status-badge-label]]:shrink [&_[data-slot=status-badge-label]]:overflow-hidden [&_[data-slot=status-badge-label]]:text-ellipsis'
/>
}
>
{remark}
</TooltipTrigger>
<TooltipContent>
@@ -191,6 +210,14 @@ export function useUsersColumns(): ColumnDef<User>[] {
return <StatusBadge variant='neutral'>{t('No Quota')}</StatusBadge>
}
const toInlineQuota = (value: number) => {
const full = formatQuota(value)
if (full.length <= MAX_INLINE_QUOTA_CHARS) {
return full
}
return formatQuotaWithCurrency(value, { compact: true, locale })
}
return (
<Tooltip>
<TooltipTrigger
@@ -198,12 +225,12 @@ export function useUsersColumns(): ColumnDef<User>[] {
<div className='w-full cursor-help space-y-1 sm:w-[150px]' />
}
>
<div className='flex justify-between text-xs'>
<span className='font-medium tabular-nums'>
{formatQuota(remaining)}
<div className='flex justify-between gap-2 text-xs'>
<span className='truncate font-medium tabular-nums'>
{toInlineQuota(remaining)}
</span>
<span className='text-muted-foreground tabular-nums'>
{formatQuota(total)}
<span className='text-muted-foreground truncate tabular-nums'>
{toInlineQuota(total)}
</span>
</div>
<Progress
+21 -30
View File
@@ -18,40 +18,31 @@ For commercial licensing, please contact support@quantumnous.com
*/
import type { CSSProperties } from 'react'
export type UserAvatarStyle = Pick<CSSProperties, 'backgroundImage' | 'color'>
import { getIdentityTextColorClass } from '@/lib/colors'
/*
* Curated duotone gradients (avatar.vercel.sh style). A raw hash-to-hue
* mapping lands on murky olive/mustard tones for many names; a hand-picked
* palette keeps every user identity vivid and readable (white initial) in
* both light and dark themes.
*/
const AVATAR_GRADIENTS: ReadonlyArray<readonly [string, string]> = [
['#4f46e5', '#7c3aed'], // indigo -> violet
['#2563eb', '#0891b2'], // blue -> cyan
['#7c3aed', '#db2777'], // violet -> pink
['#e11d48', '#ea580c'], // rose -> orange
['#059669', '#0d9488'], // emerald -> teal
['#0284c7', '#4f46e5'], // sky -> indigo
['#db2777', '#e11d48'], // pink -> rose
['#0d9488', '#0284c7'], // teal -> sky
]
function hashString(value: string): number {
let hash = 0
for (let i = 0; i < value.length; i++) {
hash = (hash * 31 + value.charCodeAt(i)) >>> 0
}
return hash
export interface UserAvatarProps {
className: string
style: CSSProperties
}
export function getUserAvatarStyle(name: string): UserAvatarStyle {
const [from, to] =
AVATAR_GRADIENTS[hashString(name) % AVATAR_GRADIENTS.length]
/**
* Soft identity tint (Linear/Notion style): a low-opacity wash of the user's
* stable identity hue, the initial in the full-strength hue, and a faint
* matching inner ring. The class sets the identity text color and the inline
* style derives background/ring from `currentColor` with the same mix ratios
* as identity badges, so avatars match group/model badges and adapt to
* light/dark themes automatically. Inline styles also win over the fallback's
* default `bg-muted` deterministically.
*/
export function getUserAvatarProps(name: string): UserAvatarProps {
return {
backgroundImage: `linear-gradient(135deg, ${from} 0%, ${to} 100%)`,
color: 'white',
className: getIdentityTextColorClass(name),
style: {
backgroundColor:
'color-mix(in oklch, currentColor var(--identity-surface-mix), transparent)',
boxShadow:
'inset 0 0 0 1px color-mix(in oklch, currentColor var(--identity-border-mix), transparent)',
},
}
}