♻️ refactor(web): refine data-table cards and pricing page layout

Replace collapsible card details with always-visible label/value rows, add badge-list full display for cards, and polish pricing/channel toolbars and mobile cards.
This commit is contained in:
t0ng7u
2026-07-11 06:02:16 +08:00
parent 0918bdb49a
commit 9d1ca545e2
68 changed files with 1971 additions and 1840 deletions
@@ -212,7 +212,7 @@ export function DynamicPricingBreakdown({
</div>
</div>
)}
<div className='text-muted-foreground mb-1 text-xs font-medium tracking-wider uppercase'>
<div className='text-muted-foreground mb-1 text-xs font-medium'>
{t('Raw expression')}
</div>
<code className='text-muted-foreground block text-xs break-all'>
@@ -275,10 +275,7 @@ export function DynamicPricingBreakdown({
)}
>
<div className='mb-1.5 flex flex-wrap items-center gap-1.5'>
<Badge
variant='secondary'
className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
>
<Badge variant='outline'>
{tier.label || t('Default')}
</Badge>
{isMatched && (
@@ -302,12 +299,12 @@ export function DynamicPricingBreakdown({
)
return (
<div key={v.field} className='min-w-0'>
<div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
<div className='text-muted-foreground truncate text-xs font-medium'>
{t(v.shortLabel)}
</div>
<div
className={cn(
'truncate font-mono',
'truncate tabular-nums',
compact ? 'text-xs' : 'text-sm font-semibold'
)}
>
@@ -357,10 +354,7 @@ export function DynamicPricingBreakdown({
return (
<>
<div className='flex flex-wrap items-center gap-1.5'>
<Badge
variant='secondary'
className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
>
<Badge variant='outline'>
{tier.label || t('Default')}
</Badge>
{isMatched && (
@@ -389,7 +383,7 @@ export function DynamicPricingBreakdown({
compact && 'h-8'
),
cellClassName: cn(
'text-right align-top font-mono',
'text-right align-top tabular-nums',
compact ? 'py-2' : 'py-2.5'
),
cell: (tier: ParsedTier) => {
+1 -5
View File
@@ -23,9 +23,5 @@ export { ModelCardGrid } from './model-card-grid'
export { LoadingSkeleton } from './loading-skeleton'
export { EmptyState } from './empty-state'
export { SearchBar } from './search-bar'
export {
ModelDetails,
ModelDetailsContent,
ModelDetailsDrawer,
} from './model-details'
export { ModelDetails, ModelDetailsContent } from './model-details'
export { PricingTable } from './pricing-table'
@@ -18,28 +18,55 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { Skeleton } from '@/components/ui/skeleton'
import { VIEW_MODES, type ViewMode } from '../constants'
import { DEFAULT_VIEW_MODE, VIEW_MODES, type ViewMode } from '../constants'
const CARD_SKELETONS = [
'card-1',
'card-2',
'card-3',
'card-4',
'card-5',
'card-6',
'card-7',
'card-8',
'card-9',
]
const PRICE_COLUMNS = ['input', 'cached', 'output', 'groups']
const TABLE_ROWS = [
'row-1',
'row-2',
'row-3',
'row-4',
'row-5',
'row-6',
'row-7',
'row-8',
'row-9',
'row-10',
]
const PAGINATION_ITEMS = ['previous', 'page-1', 'page-2', 'next']
export interface LoadingSkeletonProps {
viewMode?: ViewMode
}
export function LoadingSkeleton(props: LoadingSkeletonProps) {
const viewMode = props.viewMode ?? VIEW_MODES.CARD
const viewMode = props.viewMode ?? DEFAULT_VIEW_MODE
return (
<div className='space-y-5'>
<div className='space-y-1.5'>
<Skeleton className='h-8 w-40' />
<Skeleton className='h-4 w-52' />
<div>
<div className='mb-8 max-w-3xl space-y-2'>
<Skeleton className='h-6 w-48' />
<Skeleton className='h-4 w-full max-w-xl' />
</div>
<div className='space-y-4'>
<FilterBarSkeleton />
{viewMode === VIEW_MODES.TABLE ? (
<TableContentSkeleton />
) : (
<CardContentSkeleton />
)}
</div>
<Skeleton className='h-10 w-full rounded-lg' />
<FilterBarSkeleton />
{viewMode === VIEW_MODES.TABLE ? (
<TableContentSkeleton />
) : (
<CardContentSkeleton />
)}
</div>
)
}
@@ -47,11 +74,11 @@ export function LoadingSkeleton(props: LoadingSkeletonProps) {
function CardContentSkeleton() {
return (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className='rounded-xl border p-5'>
{CARD_SKELETONS.map((key) => (
<div key={key} className='rounded-lg border p-4'>
<div className='flex items-start justify-between gap-3'>
<div className='flex min-w-0 items-start gap-3'>
<Skeleton className='size-10 shrink-0 rounded-xl' />
<Skeleton className='size-9 shrink-0 rounded-lg' />
<div className='min-w-0 flex-1 space-y-2'>
<Skeleton className='h-5 w-36' />
<Skeleton className='h-3.5 w-48' />
@@ -80,64 +107,41 @@ function CardContentSkeleton() {
function FilterBarSkeleton() {
return (
<div className='space-y-3'>
<div className='flex items-center gap-3'>
<div className='flex flex-1 flex-wrap items-center gap-2'>
{[80, 90, 75, 85, 70].map((width, i) => (
<Skeleton
key={i}
className='h-8 rounded-lg'
style={{ width: `${width}px` }}
/>
))}
</div>
<div className='flex items-center gap-2'>
<Skeleton className='h-8 w-24 rounded-lg' />
<Skeleton className='h-8 w-20 rounded-lg' />
<Skeleton className='h-8 w-24' />
<Skeleton className='h-8 w-20 rounded-lg' />
<div>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center'>
<Skeleton className='h-7 w-full sm:h-8 sm:max-w-sm' />
<div className='flex flex-wrap items-center gap-2 sm:ml-auto'>
<Skeleton className='h-7 w-20 sm:h-8' />
<Skeleton className='h-7 w-24 sm:h-8' />
<Skeleton className='h-7 w-28 sm:h-8' />
<Skeleton className='h-7 w-16 sm:h-8' />
</div>
</div>
<Skeleton className='h-5 w-24' />
<Skeleton className='mt-3 h-4 w-24' />
</div>
)
}
function TableContentSkeleton() {
const columns = [
{ width: 200 },
{ width: 100 },
{ width: 100 },
{ width: 100 },
{ width: 80 },
{ width: 100 },
]
return (
<div className='space-y-4'>
<div className='overflow-hidden rounded-lg border'>
<div className='bg-muted/30 border-b px-4 py-3'>
<div className='flex items-center gap-4'>
{columns.map((col, i) => (
<Skeleton
key={i}
className='h-4'
style={{ width: `${col.width}px` }}
/>
<div className='grid grid-cols-[minmax(200px,2fr)_repeat(3,minmax(100px,1fr))_minmax(120px,1fr)] gap-4'>
<Skeleton className='h-4 w-32' />
{PRICE_COLUMNS.map((column) => (
<Skeleton key={column} className='h-4 w-20' />
))}
</div>
</div>
{Array.from({ length: 10 }).map((_, i) => (
{TABLE_ROWS.map((row) => (
<div
key={i}
className='flex items-center gap-4 border-b px-4 py-3 last:border-b-0'
key={row}
className='grid grid-cols-[minmax(200px,2fr)_repeat(3,minmax(100px,1fr))_minmax(120px,1fr)] gap-4 border-b px-4 py-3 last:border-b-0'
>
{columns.map((col, j) => (
<Skeleton
key={j}
className='h-5'
style={{ width: `${col.width}px` }}
/>
<Skeleton className='h-5 w-40' />
{PRICE_COLUMNS.map((column) => (
<Skeleton key={`${row}-${column}`} className='h-5 w-20' />
))}
</div>
))}
@@ -145,8 +149,8 @@ function TableContentSkeleton() {
<div className='flex items-center justify-between'>
<Skeleton className='h-5 w-32' />
<div className='flex items-center gap-2'>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className='size-8' />
{PAGINATION_ITEMS.map((item) => (
<Skeleton key={item} className='size-8' />
))}
</div>
</div>
@@ -18,13 +18,16 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { getPerfMetricsSummary } from '@/features/performance-metrics/api'
import { DEFAULT_PRICING_PAGE_SIZE, DEFAULT_TOKEN_UNIT } from '../constants'
import {
DEFAULT_PRICING_CARD_PAGE_SIZE,
DEFAULT_TOKEN_UNIT,
} from '../constants'
import type { PricingModel, TokenUnit } from '../types'
import { ModelCard } from './model-card'
import type { ModelPerfBadgeData } from './model-perf-badge'
@@ -42,11 +45,15 @@ export interface ModelCardGridProps {
export function ModelCardGrid(props: ModelCardGridProps) {
const { t } = useTranslation()
const [page, setPage] = useState(1)
const pageSize = DEFAULT_PRICING_PAGE_SIZE
const pageSize = DEFAULT_PRICING_CARD_PAGE_SIZE
const tokenUnit = props.tokenUnit ?? DEFAULT_TOKEN_UNIT
const totalPages = Math.max(1, Math.ceil(props.models.length / pageSize))
const currentPage = Math.min(page, totalPages)
useEffect(() => {
setPage(1)
}, [props.models])
const perfQuery = useQuery({
queryKey: ['perf-metrics-summary', 24],
queryFn: () => getPerfMetricsSummary(24),
@@ -73,7 +80,7 @@ export function ModelCardGrid(props: ModelCardGridProps) {
return (
<div className='space-y-4 sm:space-y-5'>
<div className='grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'>
{pagedModels.map((model) => (
<ModelCard
key={model.id ?? model.model_name}
@@ -90,7 +97,7 @@ export function ModelCardGrid(props: ModelCardGridProps) {
</div>
{totalPages > 1 && (
<div className='text-muted-foreground flex flex-col items-center justify-between gap-3 border-t px-4 py-3 text-sm sm:flex-row'>
<div className='text-muted-foreground flex flex-col items-center justify-between gap-3 border-t py-4 text-sm sm:flex-row'>
<p className='text-muted-foreground'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
@@ -105,7 +112,7 @@ export function ModelCardGrid(props: ModelCardGridProps) {
disabled={currentPage <= 1}
className='gap-1.5'
>
<ChevronLeft className='size-4' />
<ChevronLeft aria-hidden='true' />
{t('Previous page')}
</Button>
<Button
@@ -118,7 +125,7 @@ export function ModelCardGrid(props: ModelCardGridProps) {
className='gap-1.5'
>
{t('Next page')}
<ChevronRight className='size-4' />
<ChevronRight aria-hidden='true' />
</Button>
</div>
</div>
+187 -201
View File
@@ -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 { ChevronRight, Copy } from 'lucide-react'
import { ArrowRight, Copy } from 'lucide-react'
import { memo, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { StatusBadge } from '@/components/status-badge'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
import { DEFAULT_TOKEN_UNIT } from '../constants'
import {
@@ -47,6 +47,31 @@ export interface ModelCardProps {
perf?: ModelPerfBadgeData
}
function PriceMetric(props: {
label: string
value: string
unit: string
muted?: boolean
}) {
return (
<div className='min-w-0'>
<p className='text-muted-foreground text-xs'>{props.label}</p>
<p
className={
props.muted
? 'text-muted-foreground mt-1 truncate text-sm tabular-nums'
: 'text-foreground mt-1 truncate text-sm font-semibold tabular-nums'
}
>
{props.value}
<span className='text-muted-foreground ml-1 text-xs font-normal'>
/ {props.unit}
</span>
</p>
</div>
)
}
export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
const { t } = useTranslation()
const { copyToClipboard } = useCopyToClipboard()
@@ -54,235 +79,196 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
const priceRate = props.priceRate ?? 1
const usdExchangeRate = props.usdExchangeRate ?? 1
const showRechargePrice = props.showRechargePrice ?? false
const tokenUnitLabel = `${tokenUnit === 'K' ? '1K' : '1M'} ${t('tokens')}`
const isTokenBased = isTokenBasedModel(props.model)
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
const tags = parseTags(props.model.tags)
const groups = props.model.enable_groups || []
const endpoints = props.model.supported_endpoint_types || []
const modelIconKey = props.model.icon || props.model.vendor_icon
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 28) : null
const initial = props.model.model_name?.charAt(0).toUpperCase() || '?'
const isDynamicPricing =
props.model.billing_mode === 'tiered_expr' &&
Boolean(props.model.billing_expr)
const hasCachedPrice = isTokenBased && props.model.cache_ratio != null
const dynamicSummary = isDynamicPricing
? getDynamicPricingSummary(props.model, {
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 24) : null
const tags = parseTags(props.model.tags)
const endpoints = props.model.supported_endpoint_types || []
const groups = props.model.enable_groups || []
const visibleTags = [...endpoints.slice(0, 2), ...tags.slice(0, 2)]
const hiddenTagCount =
Math.max(endpoints.length - 2, 0) + Math.max(tags.length - 2, 0)
const dynamicSummary = getDynamicPricingSummary(props.model, {
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatioMultiplier: getDynamicDisplayGroupRatio(
props.model,
props.selectedGroup
),
})
const inputPrice = isTokenBased
? formatPrice(
props.model,
'input',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatioMultiplier: getDynamicDisplayGroupRatio(
props.selectedGroup
)
: ''
const outputPrice = isTokenBased
? formatPrice(
props.model,
'output',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
props.selectedGroup
)
: ''
const cachedPrice =
isTokenBased && props.model.cache_ratio != null
? formatPrice(
props.model,
'cache',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
props.selectedGroup
),
})
: null
)
: null
const primaryGroup = groups[0]
const bottomTags = [...endpoints.slice(0, 2), ...tags.slice(0, 2)]
const hiddenCount =
Math.max(groups.length - 1, 0) +
Math.max(endpoints.length - 2, 0) +
Math.max(tags.length - 2, 0)
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation()
copyToClipboard(props.model.model_name || '')
}
let priceSummary: ReactNode
if (dynamicSummary) {
if (dynamicSummary.isSpecialExpression) {
priceSummary = (
<span className='min-w-0'>
<span className='text-warning'>
{t('Special billing expression')}
</span>
<code className='text-muted-foreground/70 mt-0.5 line-clamp-1 block font-mono text-xs break-all'>
{dynamicSummary.rawExpression}
</code>
</span>
)
} else if (dynamicSummary.primaryEntries.length > 0) {
priceSummary = (
<>
{dynamicSummary.primaryEntries.map((entry) => (
<span
key={entry.key}
className='text-muted-foreground whitespace-nowrap'
>
{t(entry.shortLabel)}{' '}
<span className='text-foreground font-mono font-semibold'>
{entry.formatted}
</span>
/{tokenUnitLabel}
</span>
))}
</>
)
} else {
priceSummary = (
<span className='text-muted-foreground text-xs'>
{t('Dynamic Pricing')}
</span>
)
}
let priceContent: ReactNode
if (dynamicSummary?.isSpecialExpression) {
priceContent = (
<div>
<p className='text-warning text-sm font-medium'>
{t('Special billing expression')}
</p>
<code className='text-muted-foreground mt-1 line-clamp-2 block font-mono text-xs break-all'>
{dynamicSummary.rawExpression}
</code>
</div>
)
} else if (dynamicSummary && dynamicSummary.primaryEntries.length > 0) {
priceContent = (
<div className='grid grid-cols-2 gap-4'>
{dynamicSummary.primaryEntries.slice(0, 2).map((entry) => (
<PriceMetric
key={entry.key}
label={t(entry.shortLabel)}
value={entry.formatted}
unit={tokenUnitLabel}
/>
))}
</div>
)
} else if (isTokenBased) {
priceSummary = (
<>
<span className='text-muted-foreground whitespace-nowrap'>
{t('Input')}{' '}
<span className='text-foreground font-mono font-semibold'>
{formatPrice(
props.model,
'input',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
props.selectedGroup
)}
</span>
/{tokenUnitLabel}
</span>
<span className='text-muted-foreground whitespace-nowrap'>
{t('Output')}{' '}
<span className='text-foreground font-mono font-semibold'>
{formatPrice(
props.model,
'output',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
props.selectedGroup
)}
</span>
/{tokenUnitLabel}
</span>
{hasCachedPrice && (
<span className='text-muted-foreground/60 whitespace-nowrap'>
{t('Cached')}{' '}
<span className='font-mono'>
{formatPrice(
props.model,
'cache',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
props.selectedGroup
)}
</span>
</span>
priceContent = (
<div className='grid grid-cols-2 gap-4'>
<PriceMetric
label={t('Input')}
value={inputPrice}
unit={tokenUnitLabel}
/>
<PriceMetric
label={t('Output')}
value={outputPrice}
unit={tokenUnitLabel}
/>
{cachedPrice && (
<PriceMetric
label={t('Cached input')}
value={cachedPrice}
unit={tokenUnitLabel}
muted
/>
)}
</>
</div>
)
} else {
priceSummary = (
<span className='text-muted-foreground whitespace-nowrap'>
<span className='text-foreground font-mono font-semibold'>
{formatRequestPrice(
props.model,
showRechargePrice,
priceRate,
usdExchangeRate,
props.selectedGroup
)}
</span>{' '}
/ {t('request')}
</span>
priceContent = (
<PriceMetric
label={t('Price')}
value={formatRequestPrice(
props.model,
showRechargePrice,
priceRate,
usdExchangeRate,
props.selectedGroup
)}
unit={t('request')}
/>
)
}
return (
<div
className={cn(
'group relative flex flex-col rounded-xl border p-3 transition-colors sm:p-5',
'hover:bg-muted/20'
)}
>
{/* Header: icon + name + price + actions */}
<div className='flex items-start justify-between gap-2.5 sm:gap-3'>
<div className='flex min-w-0 items-start gap-2.5 sm:gap-3'>
<div className='bg-muted/40 flex size-9 shrink-0 items-center justify-center rounded-lg sm:size-10 sm:rounded-xl'>
{modelIcon || (
<span className='text-muted-foreground text-sm font-bold'>
{initial}
</span>
)}
</div>
<div className='min-w-0'>
<h3 className='text-foreground truncate font-mono text-[15px] leading-tight font-bold'>
{props.model.model_name}
</h3>
<div className='mt-0.5 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs sm:mt-1 sm:gap-x-3'>
{priceSummary}
</div>
</div>
<article className='hover:bg-muted/20 flex min-h-full flex-col rounded-lg border p-4 transition-colors'>
<div className='flex items-start gap-3'>
<div className='bg-muted flex size-9 shrink-0 items-center justify-center rounded-lg'>
{modelIcon || (
<span className='text-muted-foreground text-sm font-medium'>
{props.model.model_name?.charAt(0).toUpperCase() || '?'}
</span>
)}
</div>
<div className='flex shrink-0 items-center gap-1.5'>
<button
type='button'
onClick={props.onClick}
className='text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs transition-colors sm:px-2.5 sm:py-1.5'
>
{t('Details')}
<ChevronRight className='size-3.5' />
</button>
<button
type='button'
onClick={handleCopy}
className='text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border p-1.5 transition-colors'
title={t('Copy')}
>
<Copy className='size-3.5' />
</button>
<div className='min-w-0 flex-1'>
<div className='flex min-w-0 items-center gap-2'>
<h2 className='truncate font-mono text-sm font-medium'>
{props.model.model_name}
</h2>
<Button
type='button'
variant='ghost'
size='icon-xs'
onClick={() => copyToClipboard(props.model.model_name || '')}
aria-label={t('Copy model name')}
>
<Copy aria-hidden='true' className='size-3' />
</Button>
</div>
<p className='text-muted-foreground mt-0.5 text-xs'>
{props.model.vendor_name ||
(isTokenBased ? t('Token-based') : t('Per Request'))}
</p>
</div>
<ModelPerfBadge perf={props.perf} />
</div>
{/* Description */}
<p className='text-muted-foreground mt-2 line-clamp-1 flex-1 text-[13px] leading-relaxed sm:mt-4 sm:line-clamp-2 sm:min-h-[2.5rem]'>
<p className='text-muted-foreground mt-4 line-clamp-2 min-h-10 text-sm leading-relaxed'>
{props.model.description || t('No description available.')}
</p>
{/* Footer: left metadata and right performance summary share row alignment */}
<div className='mt-2 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 gap-y-1 sm:mt-4'>
<div className='flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1'>
{primaryGroup && (
<span className='text-muted-foreground text-xs font-medium'>
{primaryGroup} {t('Groups')}
</span>
)}
<span className='text-muted-foreground text-xs font-medium'>
{isTokenBased ? t('Token-based') : t('Per Request')}
</span>
{isDynamicPricing && (
<StatusBadge variant='warning' size='sm'>
{t('Dynamic Pricing')}
</StatusBadge>
)}
</div>
<ModelPerfBadge perf={props.perf} className='row-span-2 self-start' />
<div className='mt-5 border-y py-4'>{priceContent}</div>
<div className='flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-0.5 sm:gap-x-3 sm:gap-y-1'>
{bottomTags.map((item) => (
<span key={item} className='text-muted-foreground/70 text-xs'>
{item}
</span>
<div className='mt-4 flex flex-1 flex-col justify-end gap-4'>
<div className='flex min-h-6 flex-wrap items-center gap-1.5'>
{groups.slice(0, 1).map((group) => (
<StatusBadge key={group} variant='neutral' size='md'>
{group}
</StatusBadge>
))}
<span className='text-muted-foreground/50 text-xs'>
{tokenUnitLabel}
</span>
{hiddenCount > 0 && (
<span className='text-muted-foreground/40 text-xs'>
+{hiddenCount}
{visibleTags.map((tag) => (
<StatusBadge key={tag} variant='neutral' size='md'>
{tag}
</StatusBadge>
))}
{hiddenTagCount > 0 && (
<span className='text-muted-foreground text-xs'>
+{hiddenTagCount}
</span>
)}
</div>
<Button
type='button'
variant='ghost'
onClick={props.onClick}
className='self-start'
>
{t('Details')}
<ArrowRight aria-hidden='true' />
</Button>
</div>
</div>
</article>
)
})
@@ -507,7 +507,7 @@ function CodeSamplesSection(props: {
<TabsTrigger
key={ep.type}
value={ep.type}
className='h-7 px-2.5 text-xs'
className='px-2.5 text-xs'
>
{ep.type}
</TabsTrigger>
@@ -523,7 +523,7 @@ function CodeSamplesSection(props: {
>
<TabsList className='bg-muted/40 p-0.5'>
{(Object.keys(LANG_LABELS) as Lang[]).map((l) => (
<TabsTrigger key={l} value={l} className='h-7 px-2.5 text-xs'>
<TabsTrigger key={l} value={l} className='px-2.5 text-xs'>
{LANG_LABELS[l]}
</TabsTrigger>
))}
@@ -598,7 +598,7 @@ function SupportedParametersSection(props: { model: PricingModel }) {
cell: (p) => (
<Badge
variant='secondary'
className='h-7 rounded-full px-2.5 font-mono text-sm font-normal'
className='rounded-full px-2.5 font-mono text-sm font-normal'
>
{p.type}
</Badge>
@@ -51,7 +51,7 @@ function StatCard(props: {
const Icon = props.icon
return (
<div className='bg-background flex flex-col gap-1 rounded-lg border p-3'>
<span className='text-muted-foreground inline-flex items-center gap-1.5 text-xs font-medium tracking-wider uppercase'>
<span className='text-muted-foreground inline-flex items-center gap-1.5 text-xs font-medium'>
<Icon className='size-3' />
{props.label}
</span>
+254 -296
View File
@@ -28,7 +28,6 @@ import {
Layers,
Maximize2,
Sparkles,
Timer,
} from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -42,16 +41,10 @@ import {
TabsList,
TabsTrigger,
} from '@/components/design-system/tabs'
import { sideDrawerContentClassName } from '@/components/drawer-layout'
import { GroupBadge } from '@/components/group-badge'
import { PublicLayout } from '@/components/layout'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { PageTransition } from '@/components/page-transition'
import { StatusBadge } from '@/components/status-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { getPerfMetrics } from '@/features/performance-metrics/api'
import {
@@ -88,11 +81,19 @@ import { ModelDetailsPerformance } from './model-details-performance'
// Local UI helpers
// ----------------------------------------------------------------------------
function SectionTitle(props: { children: React.ReactNode }) {
function SectionTitle(props: {
children: React.ReactNode
description?: string
}) {
return (
<h2 className='text-muted-foreground mb-3 text-xs font-semibold tracking-wider uppercase'>
{props.children}
</h2>
<div className='mb-4'>
<h2 className='text-base font-semibold'>{props.children}</h2>
{props.description && (
<p className='text-muted-foreground mt-1 text-sm'>
{props.description}
</p>
)}
</div>
)
}
@@ -150,28 +151,22 @@ function normalizeCatalogItems(items?: readonly string[]): string[] {
}
function OverviewMetric(props: {
icon: React.ComponentType<{ className?: string }>
label: string
value: React.ReactNode
valueClassName?: string
}) {
const Icon = props.icon
return (
<div className='flex min-w-0 items-center gap-2 px-3 py-2'>
<Icon className='text-muted-foreground/70 size-3.5 shrink-0' />
<div className='min-w-0 flex-1'>
<div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
{props.label}
</div>
<div
className={cn(
'text-foreground truncate text-sm font-semibold tabular-nums',
props.valueClassName
)}
>
{props.value}
</div>
<div className='min-w-0 px-4 py-3'>
<div className='text-muted-foreground text-xs font-medium'>
{props.label}
</div>
<div
className={cn(
'text-foreground mt-1 truncate text-sm font-semibold tabular-nums',
props.valueClassName
)}
>
{props.value}
</div>
</div>
)
@@ -212,19 +207,13 @@ function OverviewSummaryGrid(props: { model: PricingModel }) {
: 0
return (
<div className='bg-muted/20 grid overflow-hidden rounded-lg border sm:grid-cols-3 sm:divide-x'>
<div className='divide-border grid divide-y overflow-hidden rounded-xl border sm:grid-cols-3 sm:divide-x sm:divide-y-0'>
<OverviewMetric label='TPS' value={formatThroughput(avgTps)} />
<OverviewMetric
icon={Timer}
label='TPS'
value={formatThroughput(avgTps)}
/>
<OverviewMetric
icon={Timer}
label={t('Average latency')}
value={formatLatency(avgLatency)}
/>
<OverviewMetric
icon={HeartPulse}
label={t('Success rate')}
value={formatUptimePct(successRate)}
valueClassName={getSuccessRateTextClass(successRate)}
@@ -237,12 +226,9 @@ function CatalogPillList(props: { items: string[] }) {
return (
<div className='flex min-w-0 flex-wrap gap-1.5'>
{props.items.map((item) => (
<span
key={item}
className='bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs font-medium'
>
<StatusBadge key={item} variant='neutral' size='md'>
{item}
</span>
</StatusBadge>
))}
</div>
)
@@ -250,7 +236,7 @@ function CatalogPillList(props: { items: string[] }) {
function CatalogTextValue(props: { children: React.ReactNode }) {
return (
<span className='text-foreground min-w-0 truncate text-sm font-semibold'>
<span className='text-foreground min-w-0 truncate text-sm font-medium'>
{props.children}
</span>
)
@@ -258,8 +244,8 @@ function CatalogTextValue(props: { children: React.ReactNode }) {
function CatalogInfoCell(props: { label: string; children: React.ReactNode }) {
return (
<div className='bg-card flex min-w-0 flex-col gap-1 px-3 py-2.5'>
<span className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
<div className='flex min-w-0 flex-col gap-1.5 px-4 py-3'>
<span className='text-muted-foreground text-xs font-medium'>
{props.label}
</span>
{props.children}
@@ -358,23 +344,20 @@ function ModelBackendQuickStats(props: { model: PricingModel }) {
if (stats.length === 0) return null
return (
<div className='bg-muted/20 grid grid-cols-2 gap-px overflow-hidden rounded-lg border @md/details:grid-cols-3 @2xl/details:grid-cols-5'>
<div className='divide-border grid grid-cols-2 divide-x divide-y overflow-hidden rounded-xl border @md/details:grid-cols-3 @2xl/details:grid-cols-5'>
{stats.map((stat) => {
const Icon = stat.icon
return (
<div
key={stat.key}
className='bg-background flex min-w-0 flex-col gap-0.5 px-3 py-2.5'
>
<span className='text-muted-foreground inline-flex min-w-0 items-center gap-1 text-xs font-medium tracking-wider uppercase'>
<Icon className='size-3 shrink-0' />
<div key={stat.key} className='flex min-w-0 flex-col gap-1 px-4 py-3'>
<span className='text-muted-foreground inline-flex min-w-0 items-center gap-1.5 text-xs font-medium'>
<Icon className='size-3.5 shrink-0' />
<span className='truncate'>{stat.label}</span>
</span>
<span className='text-foreground truncate text-sm font-semibold tabular-nums'>
{stat.value}
</span>
{stat.hint && (
<span className='text-muted-foreground/60 truncate text-xs'>
<span className='text-muted-foreground truncate text-xs'>
{stat.hint}
</span>
)}
@@ -401,11 +384,9 @@ function ModelBackendSignalsSection(props: { model: PricingModel }) {
return (
<section>
<SectionTitle>
{t('Capabilities')} / {t('Supported modalities')}
</SectionTitle>
<div className='grid gap-3 rounded-xl border p-3 @2xl/details:grid-cols-[minmax(0,1.5fr)_minmax(260px,1fr)]'>
{capabilities.length > 0 ? (
<SectionTitle>{t('Capabilities')}</SectionTitle>
<div className='space-y-4 rounded-xl border p-4'>
{capabilities.length > 0 && (
<CatalogPillList
items={capabilities.map((capability) =>
t(
@@ -414,13 +395,11 @@ function ModelBackendSignalsSection(props: { model: PricingModel }) {
)
)}
/>
) : (
<div />
)}
{(inputModalities.length > 0 || outputModalities.length > 0) && (
<div className='grid gap-2 sm:grid-cols-2'>
{inputModalities.length > 0 && (
<div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
<div className='bg-muted/20 flex items-center justify-between gap-3 rounded-lg px-3 py-2.5'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Input')}
</span>
@@ -430,7 +409,7 @@ function ModelBackendSignalsSection(props: { model: PricingModel }) {
</div>
)}
{outputModalities.length > 0 && (
<div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
<div className='bg-muted/20 flex items-center justify-between gap-3 rounded-lg px-3 py-2.5'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Output')}
</span>
@@ -475,7 +454,11 @@ function ModelBackendProviderSection(props: { model: PricingModel }) {
if (groups.length > 0) {
cells.push(
<CatalogInfoCell key='groups' label={t('Groups')}>
<CatalogPillList items={groups} />
<div className='flex min-w-0 flex-wrap gap-1.5'>
{groups.map((group) => (
<GroupBadge key={group} group={group} size='md' />
))}
</div>
</CatalogInfoCell>
)
}
@@ -509,7 +492,7 @@ function ModelBackendProviderSection(props: { model: PricingModel }) {
return (
<section>
<SectionTitle>{t('Model')}</SectionTitle>
<div className='border-border/60 bg-border/60 grid grid-cols-1 gap-px overflow-hidden rounded-lg border sm:grid-cols-2'>
<div className='divide-border grid grid-cols-1 overflow-hidden rounded-xl border sm:grid-cols-2 [&>*]:border-b [&>*:nth-child(odd)]:sm:border-r [&>*:nth-last-child(-n+2)]:sm:border-b-0'>
{cells}
</div>
</section>
@@ -519,7 +502,6 @@ function ModelBackendProviderSection(props: { model: PricingModel }) {
function ModelBackendDetailsSection(props: { model: PricingModel }) {
return (
<>
<ModelBackendQuickStats model={props.model} />
<ModelBackendSignalsSection model={props.model} />
<ModelBackendProviderSection model={props.model} />
</>
@@ -534,55 +516,84 @@ function ModelHeader(props: { model: PricingModel }) {
const { t } = useTranslation()
const model = props.model
const modelIconKey = model.icon || model.vendor_icon
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 20) : null
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 28) : null
const description = model.description || model.vendor_description || null
const tags = parseTags(model.tags)
const endpoints = normalizeCatalogItems(model.supported_endpoint_types)
const isSpecialExpression =
model.billing_mode === 'tiered_expr' &&
Boolean(model.billing_expr) &&
getDynamicPricingTiers(model).length === 0
return (
<header className='pb-4'>
<div className='flex items-center gap-2.5'>
{modelIcon}
<h1 className='font-mono text-xl font-bold tracking-tight sm:text-2xl'>
{model.model_name}
</h1>
<CopyButton
value={model.model_name || ''}
size='icon-xs'
iconClassName='size-3'
tooltip={t('Copy model name')}
successTooltip={t('Copied!')}
aria-label={t('Copy model name')}
/>
</div>
<div className='mt-1 flex flex-wrap items-center gap-1.5 text-xs'>
{model.vendor_name && (
<span className='text-muted-foreground'>{model.vendor_name}</span>
)}
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground/70'>
{model.quota_type === QUOTA_TYPE_VALUES.TOKEN
? t('Token-based')
: t('Per Request')}
</span>
{model.billing_mode === 'tiered_expr' && model.billing_expr && (
<>
<span className='text-muted-foreground/30'>·</span>
<span className='bg-warning/15 text-warning rounded px-1.5 py-0.5 text-xs font-medium'>
{isSpecialExpression
? t('Special billing expression')
: t('Dynamic Pricing')}
<header className='space-y-5'>
<div className='flex items-start gap-4'>
<div className='bg-muted flex size-12 shrink-0 items-center justify-center rounded-xl'>
{modelIcon || (
<span className='text-muted-foreground text-base font-medium'>
{model.model_name?.charAt(0).toUpperCase() || '?'}
</span>
</>
)}
)}
</div>
<div className='min-w-0 flex-1'>
<div className='flex min-w-0 flex-wrap items-center gap-2'>
<h1 className='truncate font-mono text-lg font-semibold tracking-tight'>
{model.model_name}
</h1>
<CopyButton
value={model.model_name || ''}
size='icon-xs'
iconClassName='size-3'
tooltip={t('Copy model name')}
successTooltip={t('Copied!')}
aria-label={t('Copy model name')}
/>
{model.billing_mode === 'tiered_expr' && model.billing_expr && (
<StatusBadge variant='warning' size='md'>
{isSpecialExpression
? t('Special billing expression')
: t('Dynamic Pricing')}
</StatusBadge>
)}
</div>
<div className='text-muted-foreground mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm'>
{model.vendor_name && <span>{model.vendor_name}</span>}
{model.vendor_name && (
<span className='text-muted-foreground/40'>·</span>
)}
<span>
{model.quota_type === QUOTA_TYPE_VALUES.TOKEN
? t('Token-based')
: t('Per Request')}
</span>
</div>
</div>
</div>
{description && (
<p className='text-muted-foreground mt-2 text-sm leading-relaxed'>
<p className='text-muted-foreground max-w-3xl text-sm leading-relaxed'>
{description}
</p>
)}
{(tags.length > 0 || endpoints.length > 0) && (
<div className='flex flex-wrap gap-1.5'>
{tags.map((tag) => (
<StatusBadge key={`tag-${tag}`} variant='neutral' size='md'>
{tag}
</StatusBadge>
))}
{endpoints.map((endpoint) => (
<StatusBadge
key={`endpoint-${endpoint}`}
variant='neutral'
size='md'
>
{endpoint}
</StatusBadge>
))}
</div>
)}
</header>
)
}
@@ -653,16 +664,16 @@ function PriceSection(props: {
if (dynamicSummary.isSpecialExpression) {
return (
<section>
<SectionTitle>{t('Base Price')}</SectionTitle>
<div className='border-warning/30 bg-warning/10 rounded-lg border p-3'>
<SectionTitle>{t('Pricing')}</SectionTitle>
<div className='border-warning/30 bg-warning/10 rounded-xl border p-4'>
<div className='text-warning text-sm font-medium'>
{t('Special billing expression')}
</div>
<p className='text-muted-foreground mt-1 text-xs'>
<p className='text-muted-foreground mt-1 text-sm'>
{t('Unable to parse structured pricing')}
</p>
<div className='mt-3'>
<div className='text-muted-foreground mb-1 text-xs font-medium tracking-wider uppercase'>
<div className='text-muted-foreground mb-1 text-xs font-medium'>
{t('Raw expression')}
</div>
<code className='text-muted-foreground bg-background/80 block max-h-28 overflow-auto rounded-md border px-2 py-1.5 font-mono text-xs break-all'>
@@ -674,55 +685,37 @@ function PriceSection(props: {
)
}
const priceRows = [
...dynamicSummary.primaryEntries,
...dynamicSummary.secondaryEntries,
]
return (
<section>
<SectionTitle>{t('Base Price')}</SectionTitle>
{dynamicSummary.primaryEntries.length > 0 ? (
<div className='grid grid-cols-2 gap-2'>
{dynamicSummary.primaryEntries.map((entry) => (
<SectionTitle>{t('Pricing')}</SectionTitle>
<div className='overflow-hidden rounded-xl border'>
<div className='flex items-baseline justify-between gap-3 border-b px-4 py-3'>
<span className='text-sm font-medium'>{t('Text tokens')}</span>
<span className='text-muted-foreground text-xs'>
{t('Prices shown per')} {tokenUnitLabel} {t('tokens')}
</span>
</div>
<div className='divide-y'>
{priceRows.map((entry) => (
<div
key={entry.key}
className='bg-muted/20 rounded-lg border p-3'
className='flex items-baseline justify-between gap-4 px-4 py-3'
>
<div className='text-muted-foreground text-xs'>
<span className='text-muted-foreground text-sm'>
{t(entry.shortLabel)}
</div>
<div className='text-foreground mt-1 text-base font-semibold tabular-nums'>
</span>
<span className='text-sm font-medium tabular-nums'>
{entry.formatted}
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
/ {tokenUnitLabel}
</span>
</div>
</span>
</div>
))}
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('Dynamic Pricing')}
</p>
)}
{dynamicSummary.secondaryEntries.length > 0 && (
<div className='bg-muted/20 mt-3 rounded-lg border px-3 py-2.5'>
<div className='space-y-1.5'>
{dynamicSummary.secondaryEntries.map((entry) => (
<div
key={entry.key}
className='flex items-baseline justify-between gap-4'
>
<span className='text-muted-foreground/70 text-sm'>
{t(entry.shortLabel)}
</span>
<span className='text-muted-foreground text-sm tabular-nums'>
{entry.formatted}
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
/ {tokenUnitLabel}
</span>
</span>
</div>
))}
</div>
</div>
)}
</div>
</section>
)
}
@@ -730,77 +723,72 @@ function PriceSection(props: {
if (!isTokenBased) {
return (
<section>
<SectionTitle>{t('Base Price')}</SectionTitle>
<div className='flex items-baseline justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Per request')}
</span>
<span className='text-foreground text-sm font-semibold tabular-nums'>
{formatFixedPrice(
props.model,
baseGroupKey,
props.showRechargePrice,
props.priceRate,
props.usdExchangeRate,
baseGroupRatioMap
)}
</span>
<SectionTitle>{t('Pricing')}</SectionTitle>
<div className='overflow-hidden rounded-xl border'>
<div className='flex items-baseline justify-between gap-4 px-4 py-3'>
<span className='text-muted-foreground text-sm'>
{t('Per request')}
</span>
<span className='text-sm font-medium tabular-nums'>
{formatFixedPrice(
props.model,
baseGroupKey,
props.showRechargePrice,
props.priceRate,
props.usdExchangeRate,
baseGroupRatioMap
)}
</span>
</div>
</div>
</section>
)
}
const secondaryItems = secondaryPriceTypes.filter((p) => p.available)
const renderPrice = (type: PriceType) => (
<>
{formatGroupPrice(
props.model,
baseGroupKey,
type,
props.tokenUnit,
props.showRechargePrice,
props.priceRate,
props.usdExchangeRate,
baseGroupRatioMap
)}
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
/ {tokenUnitLabel}
</span>
</>
)
const priceRows = [
...primaryPriceTypes,
...secondaryItems.map((item) => ({
label: item.label,
type: item.type,
})),
]
return (
<section>
<SectionTitle>{t('Base Price')}</SectionTitle>
<div className='grid grid-cols-2 gap-2'>
{primaryPriceTypes.map((item) => (
<div key={item.type} className='bg-muted/20 rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>{item.label}</div>
<div className='text-foreground mt-1 text-base font-semibold tabular-nums'>
{renderPrice(item.type)}
</div>
</div>
))}
</div>
{secondaryItems.length > 0 && (
<div className='bg-muted/20 mt-3 rounded-lg border px-3 py-2.5'>
<div className='space-y-1.5'>
{secondaryItems.map((item) => (
<div
key={item.type}
className='flex items-baseline justify-between gap-4'
>
<span className='text-muted-foreground/70 text-sm'>
{item.label}
</span>
<span className='text-muted-foreground text-sm tabular-nums'>
{renderPrice(item.type)}
</span>
</div>
))}
</div>
<SectionTitle>{t('Pricing')}</SectionTitle>
<div className='overflow-hidden rounded-xl border'>
<div className='flex items-baseline justify-between gap-3 border-b px-4 py-3'>
<span className='text-sm font-medium'>{t('Text tokens')}</span>
<span className='text-muted-foreground text-xs'>
{t('Prices shown per')} {tokenUnitLabel} {t('tokens')}
</span>
</div>
)}
<div className='divide-y'>
{priceRows.map((item) => (
<div
key={item.type}
className='flex items-baseline justify-between gap-4 px-4 py-3'
>
<span className='text-muted-foreground text-sm'>
{item.label}
</span>
<span className='text-sm font-medium tabular-nums'>
{formatGroupPrice(
props.model,
baseGroupKey,
item.type,
props.tokenUnit,
props.showRechargePrice,
props.priceRate,
props.usdExchangeRate,
baseGroupRatioMap
)}
</span>
</div>
))}
</div>
</div>
</section>
)
}
@@ -844,13 +832,13 @@ function getDynamicPriceFields(
tiers: DynamicPricingTier[],
options: DynamicPriceOptions
) {
return Array.from(
new Map(
return [
...new Map(
tiers
.flatMap((tier) => getDynamicPriceEntries(tier, options))
.map((entry) => [entry.field, entry])
).values()
)
).values(),
]
}
function getDynamicFormattedPricesByTier(
@@ -897,19 +885,24 @@ function GroupPricingSection(props: {
const extraPriceTypes = useMemo(() => {
const types: { label: string; type: PriceType }[] = []
if (props.model.cache_ratio != null)
if (props.model.cache_ratio != null) {
types.push({ label: t('Cache'), type: 'cache' })
if (props.model.create_cache_ratio != null)
}
if (props.model.create_cache_ratio != null) {
types.push({ label: t('Cache Write'), type: 'create_cache' })
if (props.model.image_ratio != null)
}
if (props.model.image_ratio != null) {
types.push({ label: t('Image'), type: 'image' })
if (props.model.audio_ratio != null)
}
if (props.model.audio_ratio != null) {
types.push({ label: t('Audio In'), type: 'audio_input' })
}
if (
props.model.audio_ratio != null &&
props.model.audio_completion_ratio != null
)
) {
types.push({ label: t('Audio Out'), type: 'audio_output' })
}
return types
}, [props.model, t])
@@ -927,8 +920,7 @@ function GroupPricingSection(props: {
)
}
const thClass =
'text-muted-foreground py-2 text-xs font-medium tracking-wider uppercase'
const thClass = 'text-muted-foreground py-2 text-xs font-medium'
if (isDynamicPricingModel(props.model)) {
const dynamicTiers = getDynamicPricingTiers(props.model)
@@ -948,7 +940,7 @@ function GroupPricingSection(props: {
)}
</p>
<div className='mt-3'>
<div className='text-muted-foreground mb-1 text-xs font-medium tracking-wider uppercase'>
<div className='text-muted-foreground mb-1 text-xs font-medium'>
{t('Raw expression')}
</div>
<code className='text-muted-foreground bg-background/80 block max-h-28 overflow-auto rounded-md border px-2 py-1.5 font-mono text-xs break-all'>
@@ -1166,53 +1158,52 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
Boolean(props.model.billing_expr)
return (
<div className='@container/details space-y-4'>
<div className='@container/details space-y-8'>
<ModelHeader model={props.model} />
<ModelBackendQuickStats model={props.model} />
<OverviewSummaryGrid model={props.model} />
<Tabs defaultValue='overview' className='gap-4'>
<TabsList className='bg-muted/60 grid w-full grid-cols-3 gap-1 rounded-lg p-1 group-data-horizontal/tabs:h-auto sm:group-data-horizontal/tabs:h-auto'>
<Tabs defaultValue='overview' className='gap-6'>
<TabsList
variant='line'
className='w-full justify-start gap-6 overflow-x-auto overflow-y-hidden border-b p-0'
>
{TAB_VALUES.map((value) => {
const Icon = TAB_META[value].icon
return (
<TabsTrigger
key={value}
value={value}
className='h-8 min-w-0 gap-1.5 rounded-md px-3 text-xs sm:text-sm'
className='flex-none gap-1.5 px-0.5 pb-3'
>
<Icon className='size-3.5' />
<Icon aria-hidden='true' className='size-3.5' />
<span className='truncate'>{t(TAB_META[value].labelKey)}</span>
</TabsTrigger>
)
})}
</TabsList>
<TabsContent value='overview' className='space-y-6 outline-none'>
<OverviewSummaryGrid model={props.model} />
<section className='bg-card/60 space-y-5 rounded-xl border p-4 shadow-sm'>
<SectionTitle>{t('Pricing')}</SectionTitle>
<PriceSection
model={props.model}
priceRate={props.priceRate}
usdExchangeRate={props.usdExchangeRate}
tokenUnit={props.tokenUnit}
showRechargePrice={showRechargePrice}
/>
{isDynamic && (
<DynamicPricingBreakdown billingExpr={props.model.billing_expr} />
)}
<GroupPricingSection
model={props.model}
groupRatio={props.groupRatio}
usableGroup={props.usableGroup}
autoGroups={props.autoGroups}
priceRate={props.priceRate}
usdExchangeRate={props.usdExchangeRate}
tokenUnit={props.tokenUnit}
showRechargePrice={showRechargePrice}
/>
</section>
<TabsContent value='overview' className='space-y-8 outline-none'>
<PriceSection
model={props.model}
priceRate={props.priceRate}
usdExchangeRate={props.usdExchangeRate}
tokenUnit={props.tokenUnit}
showRechargePrice={showRechargePrice}
/>
{isDynamic && (
<DynamicPricingBreakdown billingExpr={props.model.billing_expr} />
)}
<GroupPricingSection
model={props.model}
groupRatio={props.groupRatio}
usableGroup={props.usableGroup}
autoGroups={props.autoGroups}
priceRate={props.priceRate}
usdExchangeRate={props.usdExchangeRate}
tokenUnit={props.tokenUnit}
showRechargePrice={showRechargePrice}
/>
<ModelBackendDetailsSection model={props.model} />
</TabsContent>
@@ -1231,39 +1222,6 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
)
}
// ----------------------------------------------------------------------------
// Drawer & page wrappers
// ----------------------------------------------------------------------------
export interface ModelDetailsDrawerProps extends ModelDetailsContentProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function ModelDetailsDrawer(props: ModelDetailsDrawerProps) {
const { t } = useTranslation()
const { open, onOpenChange, ...contentProps } = props
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side='right'
className={sideDrawerContentClassName(
'sm:max-w-2xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-5xl'
)}
>
<SheetHeader className='sr-only'>
<SheetTitle>{props.model.model_name}</SheetTitle>
<SheetDescription>{t('Model details')}</SheetDescription>
</SheetHeader>
<div className='flex-1 overflow-y-auto px-4 pt-11 pb-5 sm:px-6 sm:pt-12 sm:pb-6'>
<ModelDetailsContent {...contentProps} />
</div>
</SheetContent>
</Sheet>
)
}
export function ModelDetails() {
const { t } = useTranslation()
const { modelId } = useParams({ from: '/pricing/$modelId/' })
@@ -1295,8 +1253,8 @@ export function ModelDetails() {
if (isLoading) {
return (
<PublicLayout>
<div className='mx-auto max-w-5xl px-4 sm:px-6'>
<PublicLayout showMainContainer={false}>
<div className='mx-auto max-w-6xl px-4 pt-20 pb-10 sm:px-6 lg:px-8'>
<Skeleton className='mb-4 h-5 w-16' />
<div className='space-y-2'>
<Skeleton className='h-7 w-64' />
@@ -1304,13 +1262,13 @@ export function ModelDetails() {
<Skeleton className='h-4 w-full max-w-md' />
</div>
<div className='mt-6 grid grid-cols-2 gap-2 sm:grid-cols-4'>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className='h-16 w-full' />
{['stats-a', 'stats-b', 'stats-c', 'stats-d'].map((key) => (
<Skeleton key={key} className='h-16 w-full' />
))}
</div>
<div className='mt-6 space-y-3'>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className='h-24 w-full' />
{['block-a', 'block-b', 'block-c', 'block-d'].map((key) => (
<Skeleton key={key} className='h-24 w-full' />
))}
</div>
</div>
@@ -1320,8 +1278,8 @@ export function ModelDetails() {
if (!model) {
return (
<PublicLayout>
<div className='mx-auto max-w-2xl px-4 text-center sm:px-6'>
<PublicLayout showMainContainer={false}>
<div className='mx-auto max-w-2xl px-4 pt-24 pb-10 text-center sm:px-6'>
<h2 className='mb-1 text-base font-semibold'>
{t('Model not found')}
</h2>
@@ -1337,14 +1295,14 @@ export function ModelDetails() {
}
return (
<PublicLayout>
<div className='mx-auto max-w-5xl px-4 sm:px-6'>
<PublicLayout showMainContainer={false}>
<PageTransition className='mx-auto max-w-5xl px-4 pt-20 pb-16 sm:px-6 lg:px-8'>
<Button
variant='ghost'
onClick={handleBack}
className='text-muted-foreground hover:text-foreground mb-4 h-auto gap-1 px-0 py-1 text-xs sm:h-auto'
className='text-muted-foreground hover:text-foreground mb-8 -ml-2'
>
<ArrowLeft className='size-3.5' />
<ArrowLeft aria-hidden='true' />
{t('Back')}
</Button>
@@ -1364,7 +1322,7 @@ export function ModelDetails() {
>) || {}
}
/>
</div>
</PageTransition>
</PublicLayout>
)
}
+235 -289
View File
@@ -17,18 +17,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ColumnDef } from '@tanstack/react-table'
import type { TFunction } from 'i18next'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import {
BadgeCell,
BadgeListCell,
DataTableColumnHeader,
} from '@/components/data-table'
import { BadgeListCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import { getLobeIcon } from '@/lib/lobe-icon'
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
import { DEFAULT_TOKEN_UNIT } from '../constants'
import {
getDynamicDisplayGroupRatio,
getDynamicPricingSummary,
@@ -40,11 +38,8 @@ import {
formatRequestPrice,
stripTrailingZeros,
} from '../lib/price'
import type { PricingModel, TokenUnit } from '../types'
// ----------------------------------------------------------------------------
// Pricing Table Columns
// ----------------------------------------------------------------------------
import type { PriceType, PricingModel, TokenUnit } from '../types'
import { ModelPerfBadge, type ModelPerfBadgeData } from './model-perf-badge'
export interface PricingColumnsOptions {
tokenUnit?: TokenUnit
@@ -52,352 +47,303 @@ export interface PricingColumnsOptions {
usdExchangeRate?: number
showRechargePrice?: boolean
selectedGroup?: string
perfMap?: Map<string, ModelPerfBadgeData>
}
type PriceColumnType = Extract<PriceType, 'input' | 'cache' | 'output'>
const DYNAMIC_FIELD_BY_PRICE_TYPE: Record<PriceColumnType, string> = {
input: 'inputPrice',
cache: 'cacheReadPrice',
output: 'outputPrice',
}
function renderEmptyCell(align: 'left' | 'right' = 'left'): ReactNode {
const dash = (
<span className='text-muted-foreground/50 text-sm tabular-nums'></span>
)
if (align === 'right') {
return <div className='text-right'>{dash}</div>
}
return dash
}
function renderEmptyPrice(): ReactNode {
return renderEmptyCell('right')
}
function renderPriceCell(
props: {
model: PricingModel
priceType: PriceColumnType
options: Required<
Omit<PricingColumnsOptions, 'selectedGroup' | 'perfMap'>
> & {
selectedGroup?: string
}
},
t: TFunction
): ReactNode {
const tokenUnitLabel = props.options.tokenUnit === 'K' ? '1K' : '1M'
const dynamicSummary = getDynamicPricingSummary(props.model, {
tokenUnit: props.options.tokenUnit,
showRechargePrice: props.options.showRechargePrice,
priceRate: props.options.priceRate,
usdExchangeRate: props.options.usdExchangeRate,
groupRatioMultiplier: getDynamicDisplayGroupRatio(
props.model,
props.options.selectedGroup
),
})
if (dynamicSummary?.isSpecialExpression) {
if (props.priceType !== 'input') return renderEmptyPrice()
return (
<div className='max-w-36'>
<p className='text-warning text-xs font-medium'>
{t('Special billing expression')}
</p>
<p className='text-muted-foreground mt-0.5 text-xs'>
{t('View details')}
</p>
</div>
)
}
if (dynamicSummary) {
const entry = dynamicSummary.entries.find(
(item) => item.field === DYNAMIC_FIELD_BY_PRICE_TYPE[props.priceType]
)
if (!entry) return renderEmptyPrice()
return (
<div className='text-right'>
<p className='text-sm font-medium tabular-nums'>
{stripTrailingZeros(entry.formatted)}
</p>
<p className='text-muted-foreground mt-0.5 text-xs'>
/ {tokenUnitLabel} {t('tokens')}
{dynamicSummary.tierCount > 1 &&
` · ${t('{{count}} tiers', {
count: dynamicSummary.tierCount,
})}`}
</p>
</div>
)
}
if (!isTokenBasedModel(props.model)) {
if (props.priceType !== 'input') return renderEmptyPrice()
return (
<div className='text-right'>
<p className='text-sm font-medium tabular-nums'>
{stripTrailingZeros(
formatRequestPrice(
props.model,
props.options.showRechargePrice,
props.options.priceRate,
props.options.usdExchangeRate,
props.options.selectedGroup
)
)}
</p>
<p className='text-muted-foreground mt-0.5 text-xs'>/ {t('request')}</p>
</div>
)
}
if (props.priceType === 'cache' && props.model.cache_ratio == null) {
return renderEmptyPrice()
}
return (
<div className='text-right'>
<p className='text-sm font-medium tabular-nums'>
{stripTrailingZeros(
formatPrice(
props.model,
props.priceType,
props.options.tokenUnit,
props.options.showRechargePrice,
props.options.priceRate,
props.options.usdExchangeRate,
props.options.selectedGroup
)
)}
</p>
<p className='text-muted-foreground mt-0.5 text-xs'>
/ {tokenUnitLabel} {t('tokens')}
</p>
</div>
)
}
export function usePricingColumns(
options: PricingColumnsOptions = {}
): ColumnDef<PricingModel>[] {
const { t } = useTranslation()
const {
tokenUnit = DEFAULT_TOKEN_UNIT,
priceRate = 1,
usdExchangeRate = 1,
showRechargePrice = false,
selectedGroup,
} = options
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
const priceOptions = {
tokenUnit: options.tokenUnit ?? DEFAULT_TOKEN_UNIT,
priceRate: options.priceRate ?? 1,
usdExchangeRate: options.usdExchangeRate ?? 1,
showRechargePrice: options.showRechargePrice ?? false,
selectedGroup: options.selectedGroup,
}
return [
// Model column
{
accessorKey: 'model_name',
meta: { label: t('Model') },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Model')} />
),
header: t('Model'),
cell: ({ row }) => {
const model = row.original
const modelIconKey = model.icon || model.vendor_icon
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 14) : null
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 20) : null
return (
<div className='flex max-w-full min-w-0 items-center gap-2'>
{modelIcon}
<span className='truncate font-mono text-sm font-medium'>
{model.model_name}
</span>
</div>
)
},
minSize: 200,
},
// Type column
{
accessorKey: 'quota_type',
header: t('Type'),
cell: ({ row }) => {
const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN
return (
<StatusBadge variant={isTokenBased ? 'info' : 'neutral'}>
{isTokenBased ? t('Token') : t('Request')}
</StatusBadge>
)
},
size: 80,
enableSorting: false,
},
// Price column
{
accessorKey: 'price',
meta: { label: t('Price') },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Price')} />
),
cell: ({ row }) => {
const model = row.original
const dynamicSummary = getDynamicPricingSummary(model, {
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatioMultiplier: getDynamicDisplayGroupRatio(
model,
selectedGroup
),
})
if (dynamicSummary) {
if (dynamicSummary.isSpecialExpression) {
return (
<div className='max-w-full min-w-0'>
<div className='text-warning text-xs font-medium'>
{t('Special billing expression')}
</div>
<div className='text-muted-foreground text-xs'>
{t('Unable to parse structured pricing')}
</div>
<code className='text-muted-foreground/70 mt-1 line-clamp-2 block font-mono text-xs leading-relaxed break-all'>
{dynamicSummary.rawExpression}
</code>
</div>
)
}
const primaryEntries = dynamicSummary.primaryEntries.slice(0, 2)
if (primaryEntries.length === 0) {
return (
<span className='text-muted-foreground text-xs'>
{t('Dynamic Pricing')}
</span>
)
}
return (
<div className='max-w-full min-w-0'>
<span className='text-sm tabular-nums'>
{primaryEntries.map((entry, index) => (
<span key={entry.key}>
{index > 0 && (
<span className='text-muted-foreground/40 mx-1'>/</span>
)}
{stripTrailingZeros(entry.formatted)}
</span>
))}
</span>
<div className='text-muted-foreground/50 text-xs'>
/ {tokenUnitLabel} tokens
{dynamicSummary.tierCount > 1 &&
` · ${t('{{count}} tiers', {
count: dynamicSummary.tierCount,
})}`}
</div>
<div className='flex min-w-0 items-start gap-3 py-1'>
<div className='bg-muted flex size-8 shrink-0 items-center justify-center rounded-md'>
{modelIcon || (
<span className='text-muted-foreground text-xs font-medium'>
{model.model_name?.charAt(0).toUpperCase() || '?'}
</span>
)}
</div>
)
}
const isTokenBased = isTokenBasedModel(model)
if (isTokenBased) {
const inputPrice = stripTrailingZeros(
formatPrice(
model,
'input',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
selectedGroup
)
)
const outputPrice = stripTrailingZeros(
formatPrice(
model,
'output',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
selectedGroup
)
)
return (
<div className='max-w-full min-w-0'>
<span className='text-sm tabular-nums'>
{inputPrice}
<span className='text-muted-foreground/40 mx-1'>/</span>
{outputPrice}
</span>
<div className='text-muted-foreground/50 text-xs'>
/ {tokenUnitLabel} tokens
</div>
</div>
)
}
const price = stripTrailingZeros(
formatRequestPrice(
model,
showRechargePrice,
priceRate,
usdExchangeRate,
selectedGroup
)
)
return (
<div className='max-w-full min-w-0'>
<span className='text-sm tabular-nums'>{price}</span>
<div className='text-muted-foreground/50 text-xs'>
/ {t('request')}
<div className='min-w-0'>
<p className='truncate font-mono text-sm font-medium'>
{model.model_name}
</p>
<p className='text-muted-foreground mt-0.5 line-clamp-1 text-xs'>
{model.vendor_name ||
model.description ||
(isTokenBasedModel(model)
? t('Token-based')
: t('Per Request'))}
</p>
</div>
</div>
)
},
size: 180,
minSize: 260,
enableSorting: false,
},
// Cached price column (Vercel AI Gateway style)
{
id: 'cached_price',
header: t('Cached'),
cell: ({ row }) => {
const model = row.original
const dynamicSummary = getDynamicPricingSummary(model, {
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatioMultiplier: getDynamicDisplayGroupRatio(
model,
selectedGroup
),
})
if (dynamicSummary) {
if (dynamicSummary.isSpecialExpression) {
return (
<span className='text-muted-foreground/50 text-xs'>
{t('Special billing expression')}
</span>
)
}
const cacheEntry = dynamicSummary.entries.find(
(entry) => entry.field === 'cacheReadPrice'
)
if (!cacheEntry) {
return <span className='text-muted-foreground/30 text-xs'></span>
}
return (
<div className='max-w-full min-w-0'>
<span className='text-sm tabular-nums'>
{stripTrailingZeros(cacheEntry.formatted)}
</span>
<div className='text-muted-foreground/50 text-xs'>
/ {tokenUnitLabel}
</div>
</div>
)
}
const isTokenBased = isTokenBasedModel(model)
if (!isTokenBased || model.cache_ratio == null) {
return <span className='text-muted-foreground/30 text-xs'></span>
}
const cachedPrice = stripTrailingZeros(
formatPrice(
model,
'cache',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
selectedGroup
)
)
return (
<div className='max-w-full min-w-0'>
<span className='text-sm tabular-nums'>{cachedPrice}</span>
<div className='text-muted-foreground/50 text-xs'>
/ {tokenUnitLabel}
</div>
</div>
)
},
size: 110,
enableSorting: false,
},
// Vendor column
{
accessorKey: 'vendor_name',
header: t('Vendor'),
cell: ({ row }) => {
const model = row.original
if (!model.vendor_name) {
return <span className='text-muted-foreground/50 text-xs'></span>
}
const vendorIcon = model.vendor_icon
? getLobeIcon(model.vendor_icon, 12)
: null
return (
<BadgeCell className='gap-1.5'>
{vendorIcon}
<StatusBadge variant='neutral' size='sm'>
{model.vendor_name}
</StatusBadge>
</BadgeCell>
)
},
id: 'input_price',
header: () => <div className='text-right'>{t('Input')}</div>,
cell: ({ row }) =>
renderPriceCell(
{
model: row.original,
priceType: 'input',
options: priceOptions,
},
t
),
size: 130,
enableSorting: false,
},
// Tags column
{
id: 'cached_price',
header: () => <div className='text-right'>{t('Cached input')}</div>,
cell: ({ row }) =>
renderPriceCell(
{
model: row.original,
priceType: 'cache',
options: priceOptions,
},
t
),
size: 130,
enableSorting: false,
},
{
id: 'output_price',
header: () => <div className='text-right'>{t('Output')}</div>,
cell: ({ row }) =>
renderPriceCell(
{
model: row.original,
priceType: 'output',
options: priceOptions,
},
t
),
size: 130,
enableSorting: false,
},
{
id: 'health',
header: t('Health'),
cell: ({ row }) => {
const perf = options.perfMap?.get(row.original.model_name || '')
if (!perf) {
return renderEmptyCell()
}
return <ModelPerfBadge perf={perf} className='grid' />
},
size: 160,
enableSorting: false,
},
{
accessorKey: 'tags',
header: t('Tags'),
cell: ({ row }) => {
const tags = parseTags(row.original.tags)
if (tags.length === 0) {
return renderEmptyCell()
}
return (
<BadgeListCell
items={tags.map((tag) => (
<StatusBadge key={tag} variant='neutral' size='sm'>
<StatusBadge key={tag} variant='neutral' size='md'>
{tag}
</StatusBadge>
))}
/>
)
},
size: 140,
size: 160,
enableSorting: false,
},
// Endpoints column
{
accessorKey: 'supported_endpoint_types',
header: t('Endpoints'),
cell: ({ row }) => {
const endpoints = row.original.supported_endpoint_types || []
if (endpoints.length === 0) {
return renderEmptyCell()
}
return (
<BadgeListCell
items={endpoints.map((ep) => (
<StatusBadge key={ep} variant='neutral' size='sm'>
{ep}
items={endpoints.map((endpoint) => (
<StatusBadge key={endpoint} variant='neutral' size='md'>
{endpoint}
</StatusBadge>
))}
/>
)
},
size: 130,
size: 150,
enableSorting: false,
},
// Enable Groups column
{
accessorKey: 'enable_groups',
header: t('Groups'),
cell: ({ row }) => {
const groups = row.original.enable_groups || []
if (groups.length === 0) {
return renderEmptyCell()
}
return (
<BadgeListCell
items={groups.map((group) => (
<GroupBadge key={group} group={group} size='sm' />
<GroupBadge key={group} group={group} size='md' />
))}
tooltipClassName='max-w-[280px] p-2'
tooltipClassName='max-w-72 p-2'
/>
)
},
size: 130,
size: 140,
enableSorting: false,
},
]
@@ -21,7 +21,6 @@ import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { Badge } from '@/components/ui/badge'
import {
Collapsible,
CollapsibleContent,
@@ -101,10 +100,10 @@ function FilterChip(props: {
type='button'
onClick={props.onClick}
className={cn(
'group inline-flex max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-all',
'inline-flex min-h-6 max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-colors',
props.active
? 'border-foreground/30 bg-foreground/5 text-foreground shadow-sm'
: 'border-border/70 bg-background text-muted-foreground hover:border-border hover:bg-muted/50 hover:text-foreground'
? 'border-foreground/30 bg-muted text-foreground'
: 'border-border bg-background text-muted-foreground hover:bg-muted/50 hover:text-foreground'
)}
title={props.option.label}
>
@@ -130,15 +129,15 @@ function FilterChip(props: {
function FilterSection(props: FilterSectionProps) {
return (
<Collapsible
defaultOpen
className='border-border/70 border-b pb-3 last:border-b-0'
>
<Collapsible defaultOpen className='border-b pb-3 last:border-b-0'>
<CollapsibleTrigger className='group flex w-full items-center justify-between py-2.5 text-left'>
<span className='text-foreground text-sm font-semibold'>
<span className='text-foreground text-sm font-medium'>
{props.title}
</span>
<ChevronDown className='text-muted-foreground size-4 transition-transform group-data-[panel-open]:rotate-180' />
<ChevronDown
aria-hidden='true'
className='text-muted-foreground size-4 transition-transform group-data-[panel-open]:rotate-180'
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className='flex flex-wrap gap-1.5'>
@@ -246,39 +245,25 @@ export function PricingSidebar(props: PricingSidebarProps) {
]
return (
<aside className={cn('rounded-xl border p-3', props.className)}>
<div className='mb-2.5 flex items-center justify-between gap-2'>
<div>
<h2 className='text-foreground text-sm font-bold'>{t('Filter')}</h2>
<p className='text-muted-foreground mt-1 text-xs'>
{t('Refine models by provider, group, type, and tags.')}
</p>
</div>
<aside className={cn('rounded-lg border p-3', props.className)}>
<div className='mb-2 flex items-center justify-between gap-2'>
<p className='text-muted-foreground text-xs'>
{props.hasActiveFilters
? t('Filters active')
: t('Refine models by provider, group, type, and tags.')}
</p>
<Button
type='button'
variant='ghost'
size='sm'
onClick={props.onClearFilters}
disabled={!props.hasActiveFilters}
>
<RotateCcw className='size-3.5' />
<RotateCcw aria-hidden='true' />
{t('Reset')}
</Button>
</div>
{props.hasActiveFilters && (
<Badge variant='secondary' className='mb-3'>
{t('Filters active')}
</Badge>
)}
<div className='space-y-1'>
<FilterSection
title={t('Groups')}
value={props.groupFilter}
options={groupOptions}
onChange={props.onGroupChange}
/>
<FilterSection
title={t('All Vendors')}
value={props.vendorFilter}
@@ -286,10 +271,10 @@ export function PricingSidebar(props: PricingSidebarProps) {
onChange={props.onVendorChange}
/>
<FilterSection
title={t('Model Tags')}
value={props.tagFilter}
options={tagOptions}
onChange={props.onTagChange}
title={t('Endpoint Type')}
value={props.endpointTypeFilter}
options={endpointOptions}
onChange={props.onEndpointTypeChange}
/>
<FilterSection
title={t('Pricing Type')}
@@ -298,10 +283,16 @@ export function PricingSidebar(props: PricingSidebarProps) {
onChange={props.onQuotaTypeChange}
/>
<FilterSection
title={t('Endpoint Type')}
value={props.endpointTypeFilter}
options={endpointOptions}
onChange={props.onEndpointTypeChange}
title={t('Groups')}
value={props.groupFilter}
options={groupOptions}
onChange={props.onGroupChange}
/>
<FilterSection
title={t('Model Tags')}
value={props.tagFilter}
options={tagOptions}
onChange={props.onTagChange}
/>
</div>
</aside>
@@ -16,8 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import type { Row, PaginationState } from '@tanstack/react-table'
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
@@ -26,9 +27,11 @@ import {
DataTableView,
useDataTable,
} from '@/components/data-table'
import { getPerfMetricsSummary } from '@/features/performance-metrics/api'
import { DEFAULT_PRICING_PAGE_SIZE, DEFAULT_TOKEN_UNIT } from '../constants'
import type { PricingModel, TokenUnit } from '../types'
import type { ModelPerfBadgeData } from './model-perf-badge'
import { usePricingColumns } from './pricing-columns'
export interface PricingTableProps {
@@ -60,12 +63,34 @@ export function PricingTable(props: PricingTableProps) {
pageSize: DEFAULT_PRICING_PAGE_SIZE,
})
useEffect(() => {
setPagination((current) =>
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
)
}, [models])
const perfQuery = useQuery({
queryKey: ['perf-metrics-summary', 24],
queryFn: () => getPerfMetricsSummary(24),
staleTime: 60 * 1000,
retry: false,
})
const perfMap = useMemo(() => {
const map = new Map<string, ModelPerfBadgeData>()
for (const model of perfQuery.data?.data?.models ?? []) {
map.set(model.model_name, model)
}
return map
}, [perfQuery.data])
const columns = usePricingColumns({
tokenUnit,
priceRate,
usdExchangeRate,
showRechargePrice,
selectedGroup,
perfMap,
})
const { table } = useDataTable({
@@ -87,6 +112,15 @@ export function PricingTable(props: PricingTableProps) {
[onModelClick]
)
const handleRowKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTableRowElement>, model: PricingModel) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
handleRowClick(model)
},
[handleRowClick]
)
return (
<div className='space-y-4'>
<DataTableView
@@ -103,8 +137,11 @@ export function PricingTable(props: PricingTableProps) {
<DataTableRow
key={row.id}
row={row}
className='hover:bg-muted/30 cursor-pointer transition-colors'
tabIndex={0}
aria-label={`${t('View details')}: ${row.original.model_name}`}
className='hover:bg-muted/30 focus-visible:ring-ring cursor-pointer transition-colors focus-visible:ring-2 focus-visible:outline-none'
onClick={() => handleRowClick(row.original)}
onKeyDown={(event) => handleRowKeyDown(event, row.original)}
/>
)}
/>
+121 -132
View File
@@ -17,16 +17,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ArrowUpDown, Check, Filter, Grid2X2, Table2 } from 'lucide-react'
import { useCallback, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { Tabs, TabsList, TabsTrigger } from '@/components/design-system/tabs'
import {
sideDrawerContentClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
import { Badge } from '@/components/ui/badge'
import { StatusBadge } from '@/components/status-badge'
import {
DropdownMenu,
DropdownMenuContent,
@@ -55,15 +56,12 @@ import {
} from '../constants'
import type { PricingModel, PricingVendor, TokenUnit } from '../types'
import { PricingSidebar } from './pricing-sidebar'
type SegmentOption = {
value: string
label?: string
icon?: React.ComponentType<{ className?: string }>
tooltip?: string
}
import { SearchBar } from './search-bar'
export interface PricingToolbarProps {
searchInput: string
onSearchChange: (value: string) => void
onClearSearch: () => void
filteredCount: number
totalCount?: number
sortBy: string
@@ -94,136 +92,117 @@ export interface PricingToolbarProps {
onClearFilters: () => void
}
function SegmentedControl(props: {
options: SegmentOption[]
value: string
onChange: (value: string) => void
ariaLabel: string
function PriceModeTabs(props: {
value: 'standard' | 'recharge'
onChange: (value: 'standard' | 'recharge') => void
}) {
const { t } = useTranslation()
return (
<div
role='group'
aria-label={props.ariaLabel}
className='bg-muted/60 inline-flex h-8 items-center rounded-lg border p-0.5'
<Tabs
value={props.value}
onValueChange={(value) =>
props.onChange(value as 'standard' | 'recharge')
}
>
{props.options.map((option) => {
const Icon = option.icon
const isActive = option.value === props.value
const button = (
<button
key={option.value}
type='button'
onClick={() => props.onChange(option.value)}
aria-pressed={isActive}
className={cn(
'inline-flex h-full items-center justify-center rounded-md text-xs font-medium transition-all',
Icon && !option.label ? 'w-7' : 'gap-1.5 px-3',
isActive
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
<TabsList aria-label={t('Price display mode')}>
<TabsTrigger value='standard'>{t('Standard')}</TabsTrigger>
<TabsTrigger value='recharge'>{t('Recharge')}</TabsTrigger>
</TabsList>
</Tabs>
)
}
function TokenUnitTabs(props: {
value: TokenUnit
onChange: (value: TokenUnit) => void
}) {
const { t } = useTranslation()
return (
<Tabs
value={props.value}
onValueChange={(value) => props.onChange(value as TokenUnit)}
>
<TabsList aria-label={t('Token unit')}>
<TabsTrigger value='M'>1M</TabsTrigger>
<TabsTrigger value='K'>1K</TabsTrigger>
</TabsList>
</Tabs>
)
}
function ViewModeTabs(props: {
value: ViewMode
onChange: (value: ViewMode) => void
}) {
const { t } = useTranslation()
return (
<Tabs
value={props.value}
onValueChange={(value) => props.onChange(value as ViewMode)}
>
<TabsList aria-label={t('View mode')}>
<Tooltip>
<TooltipTrigger
render={<TabsTrigger value={VIEW_MODES.TABLE} className='px-2' />}
>
{Icon && <Icon className='size-3.5' />}
{option.label}
</button>
)
if (!option.tooltip) {
return button
}
return (
<Tooltip key={option.value}>
<TooltipTrigger render={button}></TooltipTrigger>
<TooltipContent side='bottom' className='text-xs'>
{option.tooltip}
</TooltipContent>
</Tooltip>
)
})}
</div>
<Table2 aria-hidden='true' className='size-3.5' />
<span className='sr-only'>{t('Table view')}</span>
</TooltipTrigger>
<TooltipContent side='bottom'>{t('Table view')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={<TabsTrigger value={VIEW_MODES.CARD} className='px-2' />}
>
<Grid2X2 aria-hidden='true' className='size-3.5' />
<span className='sr-only'>{t('Card view')}</span>
</TooltipTrigger>
<TooltipContent side='bottom'>{t('Card view')}</TooltipContent>
</Tooltip>
</TabsList>
</Tabs>
)
}
export function PricingToolbar(props: PricingToolbarProps) {
const { t } = useTranslation()
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
const [filtersOpen, setFiltersOpen] = useState(false)
const sortLabels = getSortLabels(t)
const handleTokenUnitChange = useCallback(
(value: string) => props.onTokenUnitChange(value as TokenUnit),
[props]
)
const handleViewModeChange = useCallback(
(value: string) => props.onViewModeChange(value as ViewMode),
[props]
)
const handleRechargePriceChange = useCallback(
(value: string) => props.onRechargePriceChange(value === 'recharge'),
[props]
)
return (
<div className='rounded-xl border p-3'>
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
<div className='flex items-center gap-2'>
<div>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center'>
<SearchBar
value={props.searchInput}
onChange={props.onSearchChange}
onClear={props.onClearSearch}
placeholder={t('Search model name, provider, endpoint, or tag...')}
className='w-full sm:max-w-sm'
/>
<div className='flex flex-wrap items-center gap-2 sm:ml-auto'>
<Button
type='button'
variant='outline'
onClick={() => setMobileFiltersOpen(true)}
className='gap-1.5 xl:hidden'
onClick={() => setFiltersOpen(true)}
>
<Filter className='size-4' />
<Filter aria-hidden='true' />
{t('Filter')}
{props.activeFilterCount > 0 && (
<Badge className='ml-0.5 size-5 justify-center p-0 text-xs'>
<StatusBadge variant='neutral' size='sm'>
{props.activeFilterCount}
</Badge>
</StatusBadge>
)}
</Button>
<div className='text-muted-foreground flex items-baseline gap-1 text-sm'>
<span className='text-foreground font-semibold tabular-nums'>
{props.filteredCount.toLocaleString()}
</span>
<span>{props.filteredCount === 1 ? t('model') : t('models')}</span>
{props.hasActiveFilters && props.totalCount && (
<span className='text-muted-foreground/60 text-xs'>
/ {props.totalCount.toLocaleString()}
</span>
)}
</div>
</div>
<div className='flex flex-wrap items-center gap-2'>
<div className='hidden items-center gap-2 sm:flex'>
<SegmentedControl
options={[
{ value: 'standard', label: t('Standard') },
{ value: 'recharge', label: t('Recharge') },
]}
value={props.showRechargePrice ? 'recharge' : 'standard'}
onChange={handleRechargePriceChange}
ariaLabel={t('Price display mode')}
/>
<SegmentedControl
options={[
{ value: 'M', label: '/1M' },
{ value: 'K', label: '/1K' },
]}
value={props.tokenUnit}
onChange={handleTokenUnitChange}
ariaLabel={t('Token unit')}
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type='button' variant='outline' />}
>
<ArrowUpDown className='size-3.5' />
<ArrowUpDown aria-hidden='true' />
<span>{sortLabels[props.sortBy as SortOption] || t('Sort')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-44'>
@@ -231,11 +210,11 @@ export function PricingToolbar(props: PricingToolbarProps) {
<DropdownMenuItem
key={value}
onClick={() => props.onSortChange(value)}
className='gap-2'
>
<Check
aria-hidden='true'
className={cn(
'size-4 shrink-0',
'size-4',
props.sortBy === value ? 'opacity-100' : 'opacity-0'
)}
/>
@@ -245,27 +224,37 @@ export function PricingToolbar(props: PricingToolbarProps) {
</DropdownMenuContent>
</DropdownMenu>
<SegmentedControl
options={[
{
value: VIEW_MODES.CARD,
icon: Grid2X2,
tooltip: t('Card view'),
},
{
value: VIEW_MODES.TABLE,
icon: Table2,
tooltip: t('Table view'),
},
]}
<PriceModeTabs
value={props.showRechargePrice ? 'recharge' : 'standard'}
onChange={(value) =>
props.onRechargePriceChange(value === 'recharge')
}
/>
<TokenUnitTabs
value={props.tokenUnit}
onChange={props.onTokenUnitChange}
/>
<ViewModeTabs
value={props.viewMode}
onChange={handleViewModeChange}
ariaLabel={t('View mode')}
onChange={props.onViewModeChange}
/>
</div>
</div>
<Sheet open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
<p className='text-muted-foreground mt-3 text-sm'>
<span className='text-foreground font-medium tabular-nums'>
{props.filteredCount.toLocaleString()}
</span>{' '}
{props.filteredCount === 1 ? t('model') : t('models')}
{props.hasActiveFilters && props.totalCount != null && (
<span>
{' '}
{t('of')} {props.totalCount.toLocaleString()}
</span>
)}
</p>
<Sheet open={filtersOpen} onOpenChange={setFiltersOpen}>
<SheetContent
side='right'
className={sideDrawerContentClassName('sm:max-w-md')}
@@ -295,7 +284,7 @@ export function PricingToolbar(props: PricingToolbarProps) {
models={props.models}
hasActiveFilters={props.hasActiveFilters}
onClearFilters={props.onClearFilters}
className='border-0 bg-transparent p-0 shadow-none'
className='border-0 bg-transparent p-0'
/>
</div>
</SheetContent>
+14 -15
View File
@@ -21,6 +21,7 @@ import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { cn } from '@/lib/utils'
export interface SearchBarProps {
@@ -51,35 +52,33 @@ export function SearchBar(props: SearchBarProps) {
return (
<div className={cn('relative', props.className)}>
<Search className='text-muted-foreground/60 pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2' />
<input
<Search
aria-hidden='true'
className='text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2'
/>
<Input
ref={inputRef}
type='text'
type='search'
placeholder={props.placeholder || t('Search models...')}
value={props.value}
onChange={(e) => props.onChange(e.target.value)}
className={cn(
'border-border/60 bg-background placeholder:text-muted-foreground/50',
'hover:border-border',
'focus:border-primary/50 focus:ring-primary/20 focus:ring-2',
'h-10 w-full rounded-lg border pr-16 pl-10 text-sm transition-all outline-none'
)}
className='bg-background w-full pr-14 pl-8 [&::-webkit-search-cancel-button]:hidden'
aria-label={t('Search models')}
/>
<div className='absolute top-1/2 right-2.5 flex -translate-y-1/2 items-center gap-1'>
<div className='absolute top-1/2 right-1 flex -translate-y-1/2 items-center'>
{props.value ? (
<Button
variant='ghost'
size='icon-sm'
size='icon-xs'
onClick={props.onClear}
className='text-muted-foreground/60 hover:text-foreground'
className='text-muted-foreground hover:text-foreground'
aria-label={t('Clear search')}
>
<X className='size-4' />
<X aria-hidden='true' className='size-3.5' />
</Button>
) : (
<kbd className='bg-muted text-muted-foreground pointer-events-none hidden rounded border px-1.5 py-0.5 font-mono text-xs sm:inline-block'>
K
<kbd className='bg-muted text-muted-foreground pointer-events-none hidden rounded-md border px-1.5 py-0.5 font-mono text-xs sm:inline-block'>
Ctrl K
</kbd>
)}
</div>
+7 -1
View File
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type TFunction } from 'i18next'
import type { TFunction } from 'i18next'
import type { TokenUnit } from './types'
@@ -141,5 +141,11 @@ export const VIEW_MODES = {
export type ViewMode = (typeof VIEW_MODES)[keyof typeof VIEW_MODES]
/** Price comparison is the primary catalog experience. */
export const DEFAULT_VIEW_MODE: ViewMode = VIEW_MODES.TABLE
/** Default page size for pricing table */
export const DEFAULT_PRICING_PAGE_SIZE = 20
/** Card pages use complete rows at common desktop widths. */
export const DEFAULT_PRICING_CARD_PAGE_SIZE = 12
+37 -4
View File
@@ -25,6 +25,7 @@ import {
QUOTA_TYPES,
ENDPOINT_TYPES,
DEFAULT_TOKEN_UNIT,
DEFAULT_VIEW_MODE,
VIEW_MODES,
type ViewMode,
} from '../constants'
@@ -45,10 +46,10 @@ type FilterState = {
}
function normalizeViewMode(value: unknown): ViewMode {
if (value === VIEW_MODES.TABLE) {
return VIEW_MODES.TABLE
if (value === VIEW_MODES.CARD) {
return VIEW_MODES.CARD
}
return VIEW_MODES.CARD
return DEFAULT_VIEW_MODE
}
export function useFilters(models: PricingModel[]) {
@@ -130,7 +131,7 @@ export function useFilters(models: PricingModel[]) {
)
const setViewMode = useCallback(
(v: ViewMode) =>
updateFilters({ view: v === VIEW_MODES.CARD ? undefined : v }),
updateFilters({ view: v === DEFAULT_VIEW_MODE ? undefined : v }),
[updateFilters]
)
const setShowRechargePrice = useCallback(
@@ -186,6 +187,37 @@ export function useFilters(models: PricingModel[]) {
[vendorFilter, groupFilter, quotaTypeFilter, endpointTypeFilter, tagFilter]
)
const routeSearch = useMemo<FilterState>(
() => ({
search: searchInput || undefined,
sort: sortBy === SORT_OPTIONS.NAME ? undefined : sortBy,
vendor: vendorFilter === FILTER_ALL ? undefined : vendorFilter,
group: groupFilter === FILTER_ALL ? undefined : groupFilter,
quotaType:
quotaTypeFilter === QUOTA_TYPES.ALL ? undefined : quotaTypeFilter,
endpointType:
endpointTypeFilter === ENDPOINT_TYPES.ALL
? undefined
: endpointTypeFilter,
tag: tagFilter === FILTER_ALL ? undefined : tagFilter,
tokenUnit: tokenUnit === DEFAULT_TOKEN_UNIT ? undefined : tokenUnit,
view: viewMode === DEFAULT_VIEW_MODE ? undefined : viewMode,
rechargePrice: showRechargePrice || undefined,
}),
[
endpointTypeFilter,
groupFilter,
quotaTypeFilter,
searchInput,
showRechargePrice,
sortBy,
tagFilter,
tokenUnit,
vendorFilter,
viewMode,
]
)
const clearFilters = useCallback(() => {
updateFilters({
vendor: undefined,
@@ -225,6 +257,7 @@ export function useFilters(models: PricingModel[]) {
hasActiveFilters,
activeFilterCount,
availableTags,
routeSearch,
clearFilters,
clearSearch,
}
+91 -170
View File
@@ -16,21 +16,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useMemo, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { PublicLayout } from '@/components/layout'
import { PageTransition } from '@/components/page-transition'
import {
PublicLayout,
PublicPageHeader,
PublicPageShell,
PUBLIC_PAGE_SHELL_CLASS,
} from '@/components/layout'
import {
LoadingSkeleton,
EmptyState,
SearchBar,
PricingTable,
PricingSidebar,
PricingToolbar,
ModelCardGrid,
ModelDetailsDrawer,
} from './components'
import { EXCLUDED_GROUPS, VIEW_MODES } from './constants'
import { useFilters } from './hooks/use-filters'
@@ -38,17 +40,13 @@ import { usePricingData } from './hooks/use-pricing-data'
export function Pricing() {
const { t } = useTranslation()
const [selectedModelName, setSelectedModelName] = useState<string | null>(
null
)
const navigate = useNavigate({ from: '/pricing/' })
const {
models,
vendors,
groupRatio,
usableGroup,
endpointMap,
autoGroups,
isLoading,
priceRate,
usdExchangeRate,
@@ -79,22 +77,20 @@ export function Pricing() {
hasActiveFilters,
activeFilterCount,
availableTags,
routeSearch,
clearFilters,
clearSearch,
} = useFilters(models || [])
const handleModelClick = useCallback((modelName: string) => {
setSelectedModelName(modelName)
}, [])
const selectedModel = useMemo(
() =>
selectedModelName
? (models || []).find(
(model) => model.model_name === selectedModelName
) || null
: null,
[models, selectedModelName]
const handleModelClick = useCallback(
(modelName: string) => {
navigate({
to: '/pricing/$modelId',
params: { modelId: modelName },
search: routeSearch,
})
},
[navigate, routeSearch]
)
const availableGroups = useMemo(
@@ -110,40 +106,36 @@ export function Pricing() {
clearSearch()
}, [clearFilters, clearSearch])
const renderPricingContent = () => {
if (filteredModels.length === 0) {
return (
<EmptyState
searchQuery={searchInput}
hasActiveFilters={hasActiveFilters}
onClearFilters={handleClearAll}
/>
)
}
let pricingContent = (
<PricingTable
models={filteredModels}
priceRate={priceRate}
usdExchangeRate={usdExchangeRate}
tokenUnit={tokenUnit}
showRechargePrice={showRechargePrice}
selectedGroup={groupFilter}
onModelClick={handleModelClick}
/>
)
if (viewMode === VIEW_MODES.CARD) {
return (
<ModelCardGrid
models={filteredModels}
onModelClick={handleModelClick}
priceRate={priceRate}
usdExchangeRate={usdExchangeRate}
tokenUnit={tokenUnit}
showRechargePrice={showRechargePrice}
selectedGroup={groupFilter}
/>
)
}
return (
<PricingTable
if (filteredModels.length === 0) {
pricingContent = (
<EmptyState
searchQuery={searchInput}
hasActiveFilters={hasActiveFilters}
onClearFilters={handleClearAll}
/>
)
} else if (viewMode === VIEW_MODES.CARD) {
pricingContent = (
<ModelCardGrid
models={filteredModels}
onModelClick={handleModelClick}
priceRate={priceRate}
usdExchangeRate={usdExchangeRate}
tokenUnit={tokenUnit}
showRechargePrice={showRechargePrice}
selectedGroup={groupFilter}
onModelClick={handleModelClick}
/>
)
}
@@ -151,7 +143,7 @@ export function Pricing() {
if (isLoading) {
return (
<PublicLayout showMainContainer={false}>
<div className='mx-auto w-full max-w-[1800px] px-3 pt-16 pb-8 sm:px-6 sm:pt-20 sm:pb-10 xl:px-8'>
<div className={PUBLIC_PAGE_SHELL_CLASS}>
<LoadingSkeleton viewMode={viewMode} />
</div>
</PublicLayout>
@@ -160,130 +152,59 @@ export function Pricing() {
return (
<PublicLayout showMainContainer={false}>
<div className='relative'>
<div
aria-hidden
className='pointer-events-none absolute inset-x-0 top-0 h-[600px] opacity-20 dark:opacity-[0.10]'
style={{
background: [
'radial-gradient(ellipse 60% 50% at 20% 20%, oklch(0.72 0.18 250 / 80%) 0%, transparent 70%)',
'radial-gradient(ellipse 50% 40% at 80% 15%, oklch(0.65 0.15 200 / 60%) 0%, transparent 70%)',
'radial-gradient(ellipse 40% 35% at 50% 70%, oklch(0.70 0.12 280 / 40%) 0%, transparent 70%)',
].join(', '),
maskImage:
'linear-gradient(to bottom, black 40%, transparent 100%)',
WebkitMaskImage:
'linear-gradient(to bottom, black 40%, transparent 100%)',
}}
/>
<PageTransition className='relative mx-auto w-full max-w-[1800px] px-3 pt-16 pb-8 sm:px-6 sm:pt-20 sm:pb-10 xl:px-8'>
<header className='mx-auto mb-5 max-w-3xl pt-5 text-center sm:mb-10 sm:pt-10'>
<h1 className='text-[clamp(2rem,5.5vw,3.5rem)] leading-[1.15] font-bold tracking-tight'>
{t('Model Square')}
</h1>
<p className='text-muted-foreground/80 mt-3 text-sm sm:mt-4 sm:text-base'>
<PublicPageShell>
<PublicPageHeader
title={t('Models & pricing')}
description={
<>
{t(
'Discover curated AI models, compare pricing and capabilities, and choose the right model for every scenario.'
)}{' '}
{t('This site currently has {{count}} models enabled', {
count: models?.length || 0,
})}
</p>
<p className='text-muted-foreground/60 mx-auto mt-2 max-w-2xl text-xs leading-relaxed sm:text-sm'>
{t(
'Discover curated AI models, compare pricing and capabilities, and choose the right model for every scenario.'
)}
</p>
<SearchBar
value={searchInput}
onChange={setSearchInput}
onClear={clearSearch}
placeholder={t(
'Search model name, provider, endpoint, or tag...'
)}
className='mx-auto mt-4 max-w-2xl sm:mt-6'
/>
</header>
</>
}
/>
<div className='grid gap-4 xl:grid-cols-[330px_minmax(0,1fr)]'>
<PricingSidebar
quotaTypeFilter={quotaTypeFilter}
endpointTypeFilter={endpointTypeFilter}
vendorFilter={vendorFilter}
groupFilter={groupFilter}
tagFilter={tagFilter}
onQuotaTypeChange={setQuotaTypeFilter}
onEndpointTypeChange={setEndpointTypeFilter}
onVendorChange={setVendorFilter}
onGroupChange={setGroupFilter}
onTagChange={setTagFilter}
vendors={vendors || []}
groups={availableGroups}
groupRatios={groupRatio}
tags={availableTags}
models={models || []}
hasActiveFilters={hasActiveFilters}
onClearFilters={clearFilters}
className='hover-scrollbar sticky top-4 hidden max-h-[calc(100dvh-2rem)] self-start overflow-y-auto xl:block'
/>
<main className='space-y-4'>
<PricingToolbar
searchInput={searchInput}
onSearchChange={setSearchInput}
onClearSearch={clearSearch}
filteredCount={filteredModels.length}
totalCount={models?.length}
sortBy={sortBy}
onSortChange={setSortBy}
tokenUnit={tokenUnit}
onTokenUnitChange={setTokenUnit}
showRechargePrice={showRechargePrice}
onRechargePriceChange={setShowRechargePrice}
viewMode={viewMode}
onViewModeChange={setViewMode}
quotaTypeFilter={quotaTypeFilter}
endpointTypeFilter={endpointTypeFilter}
vendorFilter={vendorFilter}
groupFilter={groupFilter}
tagFilter={tagFilter}
onQuotaTypeChange={setQuotaTypeFilter}
onEndpointTypeChange={setEndpointTypeFilter}
onVendorChange={setVendorFilter}
onGroupChange={setGroupFilter}
onTagChange={setTagFilter}
vendors={vendors || []}
groups={availableGroups}
groupRatios={groupRatio}
tags={availableTags}
models={models || []}
hasActiveFilters={hasActiveFilters}
activeFilterCount={activeFilterCount}
onClearFilters={clearFilters}
/>
<main className='min-w-0 space-y-4'>
<PricingToolbar
filteredCount={filteredModels.length}
totalCount={models?.length}
sortBy={sortBy}
onSortChange={setSortBy}
tokenUnit={tokenUnit}
onTokenUnitChange={setTokenUnit}
showRechargePrice={showRechargePrice}
onRechargePriceChange={setShowRechargePrice}
viewMode={viewMode}
onViewModeChange={setViewMode}
quotaTypeFilter={quotaTypeFilter}
endpointTypeFilter={endpointTypeFilter}
vendorFilter={vendorFilter}
groupFilter={groupFilter}
tagFilter={tagFilter}
onQuotaTypeChange={setQuotaTypeFilter}
onEndpointTypeChange={setEndpointTypeFilter}
onVendorChange={setVendorFilter}
onGroupChange={setGroupFilter}
onTagChange={setTagFilter}
vendors={vendors || []}
groups={availableGroups}
groupRatios={groupRatio}
tags={availableTags}
models={models || []}
hasActiveFilters={hasActiveFilters}
activeFilterCount={activeFilterCount}
onClearFilters={clearFilters}
/>
{renderPricingContent()}
</main>
</div>
{selectedModel && (
<ModelDetailsDrawer
open={Boolean(selectedModel)}
onOpenChange={(open) => {
if (!open) setSelectedModelName(null)
}}
model={selectedModel}
groupRatio={groupRatio || {}}
usableGroup={usableGroup || {}}
endpointMap={
(endpointMap as Record<
string,
{ path?: string; method?: string }
>) || {}
}
autoGroups={autoGroups || []}
priceRate={priceRate ?? 1}
usdExchangeRate={usdExchangeRate ?? 1}
tokenUnit={tokenUnit}
showRechargePrice={showRechargePrice}
/>
)}
</PageTransition>
</div>
{pricingContent}
</main>
</PublicPageShell>
</PublicLayout>
)
}