♻️ 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
@@ -0,0 +1,24 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { createContext } from 'react'
export type BadgeListCellDisplay = 'compact' | 'full'
export const BadgeListCellDisplayContext =
createContext<BadgeListCellDisplay>('compact')
@@ -26,6 +26,8 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { BadgeListCellDisplayContext } from './badge-list-cell-context'
interface BadgeListCellProps {
items: React.ReactNode[]
max?: number
@@ -33,18 +35,31 @@ interface BadgeListCellProps {
}
/**
* Table cell renderer for a list of badges with overflow tooltip.
* Displays up to `max` badges inline; remaining items appear in a tooltip.
* Badge collection that stays compact in table cells and can expose every
* item when rendered inside a detail-oriented card.
*/
export function BadgeListCell({
items,
max = 2,
tooltipClassName,
}: BadgeListCellProps) {
const display = React.useContext(BadgeListCellDisplayContext)
if (items.length === 0) {
return <span className='text-muted-foreground text-xs'>-</span>
}
if (display === 'full') {
return (
<StatusBadgeList
items={items}
max={items.length}
renderItem={(item) => item}
className='flex-wrap overflow-visible'
/>
)
}
const showTooltip = items.length > max
return (
+5 -1
View File
@@ -20,6 +20,10 @@ export { DataTablePagination } from './core/pagination'
export { DataTableColumnHeader } from './core/column-header'
export { BadgeCell } from './core/badge-cell'
export { BadgeListCell } from './core/badge-list-cell'
export {
BadgeListCellDisplayContext,
type BadgeListCellDisplay,
} from './core/badge-list-cell-context'
export { TruncatedCell } from './core/truncated-cell'
export { DataTableViewOptions } from './toolbar/view-options'
export { DataTableToolbar } from './toolbar/toolbar'
@@ -55,8 +59,8 @@ export {
} from './layout/card-grid'
export { CardRowContent } from './layout/card-row-content'
export {
DataTableCardDetails,
DataTableCardField,
DataTableCardRow,
type DataTableContentMode,
} from './layout/card-field'
export { tableHasCompactMeta } from './layout/card-cell-utils'
+44 -46
View File
@@ -16,20 +16,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ChevronDown } from 'lucide-react'
import { useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import type { ReactNode } from 'react'
import { Button } from '@/components/design-system/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { cn } from '@/lib/utils'
export type DataTableContentMode = 'full' | 'wrap' | 'summary'
const VALUE_WRAP_CLASS =
'whitespace-normal break-words [overflow-wrap:anywhere] [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal [&_.whitespace-nowrap]:whitespace-normal [&_[data-slot=status-badge]]:h-auto [&_[data-slot=status-badge]]:overflow-visible [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
interface DataTableCardFieldProps {
children: ReactNode
className?: string
@@ -39,6 +34,10 @@ interface DataTableCardFieldProps {
valueClassName?: string
}
/**
* Stacked label-above-value field. Prefer {@link DataTableCardRow} for dense
* scannable cards; keep this for multi-line badge collections.
*/
export function DataTableCardField({
children,
className,
@@ -53,7 +52,7 @@ export function DataTableCardField({
className={cn('min-w-0', span === 2 && 'col-span-2', className)}
>
{label && (
<div className='text-muted-foreground mb-1 text-xs leading-none font-medium select-none'>
<div className='text-muted-foreground mb-1.5 text-xs leading-none select-none'>
{label}
</div>
)}
@@ -62,7 +61,7 @@ export function DataTableCardField({
className={cn(
'min-w-0 text-sm leading-snug',
(contentMode === 'full' || contentMode === 'wrap') &&
'whitespace-normal break-words [overflow-wrap:anywhere] [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal [&_.whitespace-nowrap]:whitespace-normal [&_[data-slot=status-badge]]:h-auto [&_[data-slot=status-badge]]:overflow-visible [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal',
VALUE_WRAP_CLASS,
contentMode === 'full' && 'break-all',
valueClassName
)}
@@ -73,49 +72,48 @@ export function DataTableCardField({
)
}
interface DataTableCardDetailsProps {
interface DataTableCardRowProps {
children: ReactNode
className?: string
count?: number
defaultOpen?: boolean
contentMode?: DataTableContentMode
label: ReactNode
valueClassName?: string
}
export function DataTableCardDetails({
/**
* Dense definition-list row: muted label left, value right.
* Always visible — no progressive disclosure / "More" click required.
*/
export function DataTableCardRow({
children,
className,
count,
defaultOpen = false,
}: DataTableCardDetailsProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(defaultOpen)
contentMode = 'wrap',
label,
valueClassName,
}: DataTableCardRowProps) {
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className={cn('mt-2', className)}
<div
data-slot='data-table-card-row'
className={cn(
'flex min-h-6 items-start justify-between gap-4 py-0.5',
className
)}
>
<CollapsibleTrigger
render={
<Button
type='button'
variant='ghost'
size='xs'
className='text-muted-foreground hover:text-foreground group/details -ml-2'
/>
}
>
{open ? t('Less') : t('More')}
{!open && count != null && count > 0 && (
<span className='tabular-nums'>({count})</span>
<span className='text-muted-foreground shrink-0 pt-0.5 text-xs select-none'>
{label}
</span>
<div
data-slot='data-table-card-value'
className={cn(
'flex min-w-0 flex-wrap items-center justify-end gap-1 text-right text-sm leading-snug',
(contentMode === 'full' || contentMode === 'wrap') &&
VALUE_WRAP_CLASS,
contentMode === 'full' && 'break-all',
valueClassName
)}
<ChevronDown className='size-3.5 transition-transform duration-150 group-data-[panel-open]/details:rotate-180' />
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-1.5 grid grid-cols-2 gap-x-3 gap-y-2 border-t pt-2'>
{children}
</div>
</CollapsibleContent>
</Collapsible>
>
{children ?? <span className='text-muted-foreground'>-</span>}
</div>
</div>
)
}
+1 -1
View File
@@ -169,7 +169,7 @@ export function DataTableCardGrid<TData>(props: DataTableCardGridProps<TData>) {
data-slot='data-table-card'
data-state={isSelected ? 'selected' : undefined}
className={cn(
'rounded-lg border bg-(--data-table-card-bg,var(--table-row)) px-3 py-2.5 transition-[background-color,border-color] duration-150 data-[state=selected]:[--data-table-card-bg:color-mix(in_oklch,var(--primary)_7%,var(--table-row))] data-[state=selected]:border-primary/40',
'rounded-lg border bg-(--data-table-card-bg,var(--table-row)) px-3.5 py-3 transition-[background-color,border-color] duration-150 data-[state=selected]:[--data-table-card-bg:color-mix(in_oklch,var(--primary)_7%,var(--table-row))] data-[state=selected]:border-primary/40',
props.getRowClassName?.(row)
)}
>
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import type { Cell, Row } from '@tanstack/react-table'
import { getCellLabel, renderCellContent } from './card-cell-utils'
import { DataTableCardDetails, DataTableCardField } from './card-field'
import { DataTableCardField, DataTableCardRow } from './card-field'
type CardRole = 'title' | 'badge' | 'primary' | 'secondary' | 'hidden'
@@ -43,26 +43,14 @@ function orderCardCells<TData>(
})
}
function CardFields<TData>({ cells }: { cells: Cell<TData, unknown>[] }) {
return cells.map((cell) => {
const meta = cell.column.columnDef.meta
return (
<DataTableCardField
key={cell.id}
label={getCellLabel(cell)}
contentMode={meta?.contentMode}
span={meta?.cardSpan}
>
{renderCellContent(cell)}
</DataTableCardField>
)
})
function isWideField<TData>(cell: Cell<TData, unknown>): boolean {
const meta = cell.column.columnDef.meta
return meta?.cardSpan === 2 || meta?.contentMode === 'summary'
}
/**
* Shared row content for both the mobile list and optional desktop card grid.
* Primary values never clip silently; lower-priority values remain available
* through the shared progressive details disclosure.
* All visible fields render immediately — no "More" click to reveal content.
*/
export function CardRowContent<TData>(props: {
row: Row<TData>
@@ -74,67 +62,84 @@ export function CardRowContent<TData>(props: {
const titleCell = cells.find((cell) => getCardRole(cell) === 'title')
const badgeCell = cells.find((cell) => getCardRole(cell) === 'badge')
const actionsCell = cells.find((cell) => cell.column.id === 'actions')
const fieldCells = orderCardCells(
const bodyCells = orderCardCells(
cells.filter(
(cell) =>
cell !== titleCell &&
cell !== badgeCell &&
cell !== actionsCell &&
getCardRole(cell) === 'primary'
)
)
const secondaryCells = orderCardCells(
cells.filter(
(cell) =>
cell !== titleCell &&
cell !== badgeCell &&
cell !== actionsCell &&
getCardRole(cell) === 'secondary'
getCardRole(cell) !== 'hidden'
)
)
const rowCells = bodyCells.filter((cell) => !isWideField(cell))
const wideCells = bodyCells.filter((cell) => isWideField(cell))
return (
<>
<div className='flex min-w-0 flex-col'>
{props.compact && (titleCell || badgeCell) && (
<div className='flex min-w-0 items-start justify-between gap-3'>
<div className='min-w-0 flex-1 text-sm font-medium [overflow-wrap:anywhere] break-words whitespace-normal [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal [&_[data-slot=status-badge-label]]:whitespace-normal [&_[data-slot=status-badge]]:h-auto [&_[data-slot=status-badge]]:max-w-full'>
<div className='min-w-0 flex-1 text-[15px] leading-tight font-semibold [overflow-wrap:anywhere] break-words whitespace-normal [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal [&_[data-slot=status-badge-label]]:whitespace-normal [&_[data-slot=status-badge]]:h-auto [&_[data-slot=status-badge]]:max-w-full'>
{titleCell ? renderCellContent(titleCell) : null}
</div>
{badgeCell && (
<DataTableCardField
contentMode={badgeCell.column.columnDef.meta?.contentMode}
className='max-w-1/2 shrink'
valueClassName='flex justify-end text-right'
>
<div className='max-w-1/2 shrink text-right'>
{renderCellContent(badgeCell)}
</DataTableCardField>
</div>
)}
</div>
)}
{fieldCells.length > 0 && (
<div
className={
props.compact
? 'mt-2 grid grid-cols-2 gap-x-3 gap-y-2'
: 'grid grid-cols-2 gap-x-3 gap-y-2'
}
>
<CardFields cells={fieldCells} />
{!props.compact && (
<div className='grid grid-cols-2 gap-x-3 gap-y-2'>
{bodyCells.map((cell) => {
const meta = cell.column.columnDef.meta
return (
<DataTableCardField
key={cell.id}
label={getCellLabel(cell)}
contentMode={meta?.contentMode}
span={meta?.cardSpan}
>
{renderCellContent(cell)}
</DataTableCardField>
)
})}
</div>
)}
{secondaryCells.length > 0 && (
<DataTableCardDetails count={secondaryCells.length}>
<CardFields cells={secondaryCells} />
</DataTableCardDetails>
{props.compact && rowCells.length > 0 && (
<div className='mt-3 space-y-0.5 border-t pt-3'>
{rowCells.map((cell) => (
<DataTableCardRow
key={cell.id}
label={getCellLabel(cell)}
contentMode={cell.column.columnDef.meta?.contentMode}
>
{renderCellContent(cell)}
</DataTableCardRow>
))}
</div>
)}
{props.compact && wideCells.length > 0 && (
<div className='mt-3 space-y-3 border-t pt-3'>
{wideCells.map((cell) => (
<DataTableCardField
key={cell.id}
label={getCellLabel(cell)}
contentMode={cell.column.columnDef.meta?.contentMode ?? 'full'}
>
{renderCellContent(cell)}
</DataTableCardField>
))}
</div>
)}
{actionsCell && (
<div className='mt-2 -mb-0.5 flex justify-end border-t pt-2'>
<div className='mt-3 flex justify-end border-t pt-2'>
{renderCellContent(actionsCell)}
</div>
)}
</>
</div>
)
}
@@ -150,7 +150,7 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
<div
key={key}
className={cn(
'[background-color:var(--data-table-card-bg,var(--table-row))] px-3 py-2.5',
'[background-color:var(--data-table-card-bg,var(--table-row))] px-3.5 py-3',
getRowClassName?.(row)
)}
>
@@ -55,6 +55,7 @@ export interface DataTableFilterPanelProps<TData> {
searchLoading?: boolean
onReset: () => void
onSearch?: () => void
inlineActions?: boolean
className?: string
}
@@ -144,6 +145,35 @@ export function DataTableFilterPanel<TData>(
<DataTableViewOptions table={props.table} />
) : null
const desktopActions = (
<div className='ms-auto flex shrink-0 flex-wrap items-center justify-end gap-1.5 sm:gap-2'>
{props.actionStart}
<Button
type='button'
variant={props.onSearch ? 'outline' : 'ghost'}
onClick={props.onReset}
disabled={!props.hasActiveFilters}
className={cn(
!props.onSearch && 'text-muted-foreground hover:text-foreground px-2'
)}
>
{t('Reset')}
</Button>
{props.onSearch && (
<Button
type='button'
onClick={props.onSearch}
disabled={props.searchLoading}
>
{props.searchLoading && <Loader2 className='animate-spin' />}
{t('Search')}
</Button>
)}
{props.viewToggle}
{viewOptions}
</div>
)
if (isMobile && props.mobilePinnedFilters != null) {
return (
<Drawer open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
@@ -264,6 +294,7 @@ export function DataTableFilterPanel<TData>(
{advancedToggle}
</div>
)}
{props.inlineActions && desktopActions}
</div>
{advancedOpen && props.advancedFilters && (
@@ -272,36 +303,12 @@ export function DataTableFilterPanel<TData>(
</div>
)}
<div className='mt-2 flex min-w-0 flex-wrap items-center gap-2'>
{props.stats}
<div className='ms-auto flex flex-wrap items-center justify-end gap-1.5 sm:gap-2'>
{props.actionStart}
<Button
type='button'
variant={props.onSearch ? 'outline' : 'ghost'}
onClick={props.onReset}
disabled={!props.hasActiveFilters}
className={cn(
!props.onSearch &&
'text-muted-foreground hover:text-foreground px-2'
)}
>
{t('Reset')}
</Button>
{props.onSearch && (
<Button
type='button'
onClick={props.onSearch}
disabled={props.searchLoading}
>
{props.searchLoading && <Loader2 className='animate-spin' />}
{t('Search')}
</Button>
)}
{props.viewToggle}
{viewOptions}
{(!props.inlineActions || props.stats != null) && (
<div className='mt-2 flex min-w-0 flex-wrap items-center gap-2'>
{props.stats}
{!props.inlineActions && desktopActions}
</div>
</div>
)}
</div>
)
}
+6 -2
View File
@@ -276,8 +276,11 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
const primarySearch =
props.customSearch !== undefined ? props.customSearch : searchInput
const useWidePrimarySearch =
filters.length + (props.additionalSearch != null ? 1 : 0) <= 3
const additionalFilterCount =
filters.length + (props.additionalSearch != null ? 1 : 0)
const inlineActions =
additionalFilterCount <= 3 && !hasExpandable && props.leftActions == null
const useWidePrimarySearch = !inlineActions && additionalFilterCount <= 3
const secondaryMobileFilters =
props.additionalSearch != null ||
filterChips.some(Boolean) ||
@@ -320,6 +323,7 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
searchLoading={props.searchLoading}
onReset={handleReset}
onSearch={hasSearch ? props.onSearch : undefined}
inlineActions={inlineActions}
className={props.className}
/>
)
+5 -1
View File
@@ -28,13 +28,17 @@ import { cn } from '@/lib/utils'
function TabsList({
className,
variant = 'default',
...props
}: React.ComponentProps<typeof ShadcnTabsList>) {
return (
<ShadcnTabsList
data-control-size='default'
variant={variant}
className={cn(
'group-data-horizontal/tabs:h-7 sm:group-data-horizontal/tabs:h-8',
variant === 'line'
? 'group-data-horizontal/tabs:h-auto sm:group-data-horizontal/tabs:h-auto'
: 'group-data-horizontal/tabs:h-7 sm:group-data-horizontal/tabs:h-8',
className
)}
{...props}
+2
View File
@@ -71,6 +71,8 @@ export function Dialog({
{trigger ? <DialogTrigger render={trigger} /> : null}
<DialogContent
className={cn(
// Default width is sm:max-w-2xl. Override with `sm:max-w-*` in
// contentClassName (bare `max-w-*` will not replace the sm: default).
'flex max-h-[calc(100vh-2rem)] w-full flex-col gap-4 overflow-hidden p-4 sm:max-w-2xl sm:p-6',
contentClassName,
dialogContentMotionClassName
+4 -1
View File
@@ -22,13 +22,16 @@ import { cn } from '@/lib/utils'
export const sideDrawerContentClassName = (className?: string) =>
cn(
// Width: pass `sm:max-w-*` (or `sm:max-w-none`) in className. SheetContent
// defaults to `sm:max-w-sm` for left/right; plain utilities merge correctly.
'bg-background text-foreground flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 shadow-none',
className
)
export const sideDrawerHeaderClassName = (className?: string) =>
cn(
'border-border/70 bg-background/95 border-b px-4 py-3 text-start backdrop-blur supports-[backdrop-filter]:bg-background/80 sm:px-6 sm:py-4',
// pr-12 reserves space for SheetContent's absolute close button
'border-border/70 bg-background/95 border-b px-4 py-3 pr-12 text-start backdrop-blur supports-[backdrop-filter]:bg-background/80 sm:px-6 sm:py-4 sm:pr-14',
className
)
@@ -0,0 +1,67 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import { PageTransition } from '@/components/page-transition'
import { cn } from '@/lib/utils'
/** Shared shell for public catalog pages (pricing, rankings, …). */
export const PUBLIC_PAGE_SHELL_CLASS =
'mx-auto w-full max-w-7xl px-4 pt-24 pb-12 sm:px-6 lg:px-8'
export interface PublicPageShellProps {
children: ReactNode
className?: string
}
export function PublicPageShell(props: PublicPageShellProps) {
return (
<PageTransition className={cn(PUBLIC_PAGE_SHELL_CLASS, props.className)}>
{props.children}
</PageTransition>
)
}
export interface PublicPageHeaderProps {
title: ReactNode
description?: ReactNode
/** Full-width slot under the title block (tabs, filters, meta). */
children?: ReactNode
className?: string
}
/**
* Shared page header for public catalog surfaces.
* Title follows the product page-title contract: text-lg / semibold / tight.
*/
export function PublicPageHeader(props: PublicPageHeaderProps) {
return (
<header className={cn('mb-8 space-y-6', props.className)}>
<div className='max-w-3xl'>
<h1 className='text-lg font-semibold tracking-tight'>{props.title}</h1>
{props.description != null && props.description !== '' && (
<p className='text-muted-foreground mt-2 text-sm leading-relaxed'>
{props.description}
</p>
)}
</div>
{props.children}
</header>
)
}
+5
View File
@@ -26,6 +26,11 @@ export { AppSidebar } from './components/app-sidebar'
export { AuthenticatedLayout } from './components/authenticated-layout'
export { PublicLayout } from './components/public-layout'
export { PublicHeader } from './components/public-header'
export {
PublicPageHeader,
PublicPageShell,
PUBLIC_PAGE_SHELL_CLASS,
} from './components/public-page-header'
export { PublicNavigation } from './components/public-navigation'
export { HeaderLogo } from './components/header-logo'
export { NavLinkItem, NavLinkList } from './components/nav-link-item'
+1 -1
View File
@@ -183,7 +183,7 @@ export function RiskAcknowledgementDialog({
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent
className={cn(
'flex max-h-[min(88dvh,760px)] w-[calc(100vw-1.5rem)] !max-w-[44rem] grid-rows-none flex-col gap-0 overflow-hidden !p-0 sm:w-[min(44rem,calc(100vw-3rem))]',
'flex max-h-[min(88dvh,760px)] w-[calc(100vw-1.5rem)] max-w-[44rem] grid-rows-none flex-col gap-0 overflow-hidden p-0 sm:w-[min(44rem,calc(100vw-3rem))] sm:max-w-[44rem]',
className
)}
>
+7 -1
View File
@@ -63,6 +63,11 @@ function AlertDialogContent({
}: AlertDialogPrimitive.Popup.Props & {
size?: 'default' | 'sm'
}) {
// Apply size max-width as plain utilities so callers can override with
// `sm:max-w-*` / `max-w-*` via tailwind-merge. Upstream uses data-[size]
// selectors that silently clamp custom widths (e.g. conflict confirm).
const sizeMaxWidthClass = size === 'sm' ? 'max-w-xs' : 'max-w-xs sm:max-w-sm'
return (
<AlertDialogPortal>
<AlertDialogOverlay />
@@ -70,7 +75,8 @@ function AlertDialogContent({
data-slot='alert-dialog-content'
data-size={size}
className={cn(
'group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
'group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
sizeMaxWidthClass,
className
)}
{...props}
+9 -1
View File
@@ -65,6 +65,13 @@ function SheetContent({
side?: 'top' | 'right' | 'bottom' | 'left'
showCloseButton?: boolean
}) {
// Apply default side max-width as a plain utility (not data-[side]-scoped) so
// callers can override with `sm:max-w-*` via tailwind-merge. Upstream shadcn
// uses `data-[side=right]:sm:max-w-sm`, which wins over custom widths by
// specificity and silently clamps wide drawers (e.g. channel mutate).
const sideMaxWidthClass =
side === 'left' || side === 'right' ? 'sm:max-w-sm' : undefined
return (
<SheetPortal>
<SheetOverlay />
@@ -72,7 +79,8 @@ function SheetContent({
data-slot='sheet-content'
data-side={side}
className={cn(
'fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm',
'fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem]',
sideMaxWidthClass,
className
)}
{...props}
+1 -1
View File
@@ -206,7 +206,7 @@ function Sidebar({
data-sidebar='sidebar'
data-slot='sidebar'
data-mobile='true'
className='bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden'
className='bg-sidebar text-sidebar-foreground w-(--sidebar-width) max-w-none p-0 sm:max-w-none [&>button]:hidden'
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
+1 -1
View File
@@ -77,7 +77,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-0 group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
className
)}
{...props}
+10 -8
View File
@@ -20,7 +20,7 @@ import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from 'react'
@@ -133,9 +133,11 @@ export function ThemeCustomizationProvider(props: {
)
)
// Mirror state to the <body> via data-* attributes so theme-presets.css can
// override CSS variables at the right cascade layer.
useEffect(() => {
// Mirror state to <body> via data-* attributes before paint so theme-
// presets.css can override CSS variables without a one-frame size/font flash.
// useLayoutEffect (not useEffect) is required: useEffect runs after paint,
// which is exactly when users see text jump from default → cookie scale/font.
useLayoutEffect(() => {
applyAttribute(
'data-theme-preset',
preset === DEFAULT_THEME_CUSTOMIZATION.preset ? null : preset
@@ -148,25 +150,25 @@ export function ThemeCustomizationProvider(props: {
// Resolving here (instead of in CSS via `:not()` selectors) keeps the
// stylesheet to one simple `[data-theme-font='serif']` selector and lets
// future presets opt into typography via `PRESET_DEFAULT_FONT` alone.
useEffect(() => {
useLayoutEffect(() => {
applyAttribute('data-theme-font', resolveThemeFont(font, preset))
}, [font, preset])
useEffect(() => {
useLayoutEffect(() => {
applyAttribute(
'data-theme-radius',
radius === DEFAULT_THEME_CUSTOMIZATION.radius ? null : radius
)
}, [radius])
useEffect(() => {
useLayoutEffect(() => {
applyAttribute(
'data-theme-scale',
scale === DEFAULT_THEME_CUSTOMIZATION.scale ? null : scale
)
}, [scale])
useEffect(() => {
useLayoutEffect(() => {
applyAttribute('data-theme-content-layout', contentLayout)
}, [contentLayout])
+3 -2
View File
@@ -20,7 +20,7 @@ import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from 'react'
@@ -88,7 +88,8 @@ export function ThemeProvider({
resolveTheme(getStoredTheme(storageKey, defaultTheme))
)
useEffect(() => {
// Apply before paint to avoid a light→dark (or reverse) flash on load.
useLayoutEffect(() => {
const root = window.document.documentElement
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
@@ -414,7 +414,7 @@ export function UserAuthForm({
description={t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
contentClassName='max-w-sm'
contentClassName='sm:max-w-sm'
headerClassName='text-left'
contentHeight='auto'
bodyClassName='space-y-4'
@@ -386,7 +386,7 @@ export function SignUpForm({
description={t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
contentClassName='max-w-sm'
contentClassName='sm:max-w-sm'
headerClassName='text-left'
contentHeight='auto'
bodyClassName='space-y-4'
+100 -101
View File
@@ -20,8 +20,9 @@ import { flexRender, type Row } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import {
DataTableCardDetails,
BadgeListCellDisplayContext,
DataTableCardField,
DataTableCardRow,
} from '@/components/data-table'
import { isTagAggregateRow } from '../lib'
@@ -31,10 +32,7 @@ import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
/**
* Bespoke channel card for the card view. Reuses every column's existing cell
* renderer via `flexRender`, so the table's information and interactions are
* preserved: row selection, provider/multi-key/IO.NET type badge, id,
* name/remark + warning icons, status (with tooltips), groups, inline
* priority/weight spinners, balance refresh, response/test times, tag
* expand-collapse, models, and the per-row (or per-tag) actions menu.
* preserved. All fields are always visible — no "More" disclosure.
*/
function ChannelCardComponent({
row,
@@ -71,111 +69,112 @@ function ChannelCardComponent({
const responseCell = renderCell('response_time')
const testCell = renderCell('test_time')
const emptyValue = <span className='text-muted-foreground'>-</span>
const detailsCount = [
visibleColumnIds.has('group'),
!isTagRow && visibleColumnIds.has('tag'),
visibleColumnIds.has('priority'),
visibleColumnIds.has('weight'),
].filter(Boolean).length
const showId = !isTagRow && visibleColumnIds.has('id')
const showTag = !isTagRow && visibleColumnIds.has('tag')
const showModels = !isTagRow && visibleColumnIds.has('models')
const showTestTime = !isTagRow && visibleColumnIds.has('test_time')
const hasStatRows =
showId ||
showTag ||
showTestTime ||
visibleColumnIds.has('balance') ||
visibleColumnIds.has('response_time') ||
visibleColumnIds.has('priority') ||
visibleColumnIds.has('weight')
const hasBadgeSections = showModels || visibleColumnIds.has('group')
return (
<ChannelRowActionsLayoutContext.Provider value='card'>
<div
data-state={isSelected ? 'selected' : undefined}
className='flex flex-col gap-3'
>
{/* Provider identity, status, selection, and every row action remain
immediately available. The wrapping layout avoids mobile clipping. */}
<div className='flex flex-wrap items-start justify-between gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2'>
<BadgeListCellDisplayContext.Provider value='full'>
<div
data-state={isSelected ? 'selected' : undefined}
className='flex h-full min-w-0 flex-col'
>
<div className='flex min-w-0 items-start gap-2.5'>
{!isTagRow && selectCell && (
<span className='shrink-0'>{selectCell}</span>
<span className='mt-0.5 shrink-0'>{selectCell}</span>
)}
{visibleColumnIds.has('type') && (
<div className='min-w-0 flex-1'>{typeCell}</div>
)}
</div>
<div className='flex flex-wrap items-center justify-end gap-1.5'>
{visibleColumnIds.has('status') && statusCell}
{actionsCell}
</div>
</div>
<div className='grid grid-cols-2 gap-x-3 gap-y-2'>
{visibleColumnIds.has('name') && (
<DataTableCardField
label={isTagRow ? t('Tag') : t('Name')}
span={2}
contentMode='wrap'
>
{nameCell ?? emptyValue}
</DataTableCardField>
<div className='min-w-0 flex-1'>
{visibleColumnIds.has('name') && (
<div className='min-w-0 text-[15px] leading-tight font-semibold break-words'>
{nameCell}
</div>
)}
{visibleColumnIds.has('type') && (
<div className='mt-1.5 min-w-0'>{typeCell}</div>
)}
</div>
<div className='flex shrink-0 items-center gap-1'>
{visibleColumnIds.has('status') && statusCell}
{actionsCell}
</div>
</div>
{hasStatRows && (
<div className='mt-3 space-y-0.5 border-t pt-3'>
{showId && (
<DataTableCardRow label={t('ID')} contentMode='full'>
{idCell}
</DataTableCardRow>
)}
{visibleColumnIds.has('balance') && (
<DataTableCardRow
label={t('Used / Remaining')}
contentMode='full'
>
{balanceCell}
</DataTableCardRow>
)}
{visibleColumnIds.has('response_time') && (
<DataTableCardRow label={t('Response')} contentMode='full'>
{responseCell}
</DataTableCardRow>
)}
{showTestTime && (
<DataTableCardRow label={t('Last Tested')} contentMode='full'>
{testCell}
</DataTableCardRow>
)}
{visibleColumnIds.has('priority') && (
<DataTableCardRow label={t('Priority')} contentMode='full'>
{priorityCell}
</DataTableCardRow>
)}
{visibleColumnIds.has('weight') && (
<DataTableCardRow label={t('Weight')} contentMode='full'>
{weightCell}
</DataTableCardRow>
)}
{showTag && (
<DataTableCardRow label={t('Tag')} contentMode='wrap'>
{tagCell}
</DataTableCardRow>
)}
</div>
)}
{!isTagRow && visibleColumnIds.has('id') && (
<DataTableCardField label={t('ID')} contentMode='full'>
{idCell ?? emptyValue}
</DataTableCardField>
)}
{visibleColumnIds.has('balance') && (
<DataTableCardField
label={t('Used / Remaining')}
span={2}
contentMode='full'
>
{balanceCell ?? emptyValue}
</DataTableCardField>
)}
{!isTagRow && visibleColumnIds.has('models') && (
<DataTableCardField
label={t('Models')}
span={2}
contentMode='summary'
>
{modelsCell ?? emptyValue}
</DataTableCardField>
)}
{visibleColumnIds.has('response_time') && (
<DataTableCardField label={t('Response')} contentMode='full'>
{responseCell ?? emptyValue}
</DataTableCardField>
)}
{!isTagRow && visibleColumnIds.has('test_time') && (
<DataTableCardField label={t('Last Tested')} contentMode='full'>
{testCell ?? emptyValue}
</DataTableCardField>
{hasBadgeSections && (
<div className='mt-3 space-y-3 border-t pt-3'>
{visibleColumnIds.has('group') && (
<DataTableCardField label={t('Groups')} contentMode='full'>
{groupsCell ?? (
<span className='text-muted-foreground'>-</span>
)}
</DataTableCardField>
)}
{showModels && (
<DataTableCardField label={t('Models')} contentMode='full'>
{modelsCell ?? (
<span className='text-muted-foreground'>-</span>
)}
</DataTableCardField>
)}
</div>
)}
</div>
{detailsCount > 0 && (
<DataTableCardDetails count={detailsCount}>
{visibleColumnIds.has('group') && (
<DataTableCardField
label={t('Groups')}
span={2}
contentMode='summary'
>
{groupsCell ?? emptyValue}
</DataTableCardField>
)}
{!isTagRow && visibleColumnIds.has('tag') && (
<DataTableCardField label={t('Tag')} span={2} contentMode='wrap'>
{tagCell ?? emptyValue}
</DataTableCardField>
)}
{visibleColumnIds.has('priority') && (
<DataTableCardField label={t('Priority')} contentMode='full'>
{priorityCell ?? emptyValue}
</DataTableCardField>
)}
{visibleColumnIds.has('weight') && (
<DataTableCardField label={t('Weight')} contentMode='full'>
{weightCell ?? emptyValue}
</DataTableCardField>
)}
</DataTableCardDetails>
)}
</div>
</BadgeListCellDisplayContext.Provider>
</ChannelRowActionsLayoutContext.Provider>
)
}
@@ -35,6 +35,7 @@ import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/design-system/button'
import { Toggle } from '@/components/design-system/toggle'
import {
DropdownMenu,
DropdownMenuContent,
@@ -44,8 +45,6 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Tooltip,
TooltipContent,
@@ -107,44 +106,37 @@ export function ChannelsPrimaryButtons() {
return (
<>
<div className='flex items-center gap-2'>
{/* Desktop: Toggle switches visible */}
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<ListChecks className='text-muted-foreground h-4 w-4' />
<Label
htmlFor='channel-batch-mode'
className='cursor-pointer text-sm'
{/* Desktop: view toggles */}
<div className='hidden items-center gap-1.5 sm:flex'>
<Toggle
variant='outline'
pressed={batchMode}
onPressedChange={handleBatchModeToggle}
aria-label={t('Batch Operations')}
>
<ListChecks />
{t('Batch Operations')}
</Label>
<Switch
id='channel-batch-mode'
checked={batchMode}
onCheckedChange={handleBatchModeToggle}
/>
</div>
</Toggle>
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<Tags className='text-muted-foreground h-4 w-4' />
<Label htmlFor='tag-mode' className='cursor-pointer text-sm'>
<Toggle
variant='outline'
pressed={enableTagMode}
onPressedChange={handleTagModeToggle}
aria-label={t('Tag Mode')}
>
<Tags />
{t('Tag Mode')}
</Label>
<Switch
id='tag-mode'
checked={enableTagMode}
onCheckedChange={handleTagModeToggle}
/>
</div>
</Toggle>
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<SortAsc className='text-muted-foreground h-4 w-4' />
<Label htmlFor='id-sort' className='cursor-pointer text-sm'>
<Toggle
variant='outline'
pressed={idSort}
onPressedChange={handleIdSortToggle}
aria-label={t('Sort by ID')}
>
<SortAsc />
{t('Sort by ID')}
</Label>
<Switch
id='id-sort'
checked={idSort}
onCheckedChange={handleIdSortToggle}
/>
</Toggle>
</div>
{/* Create Channel */}
@@ -419,7 +419,7 @@ export function ChannelsTable() {
renderCard={(row, { isSelected }) => (
<ChannelCard row={row} isSelected={isSelected} />
)}
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-3'
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 xl:grid-cols-3'
applyHeaderSize
toolbarProps={{
searchPlaceholder: t('Filter by name, ID, or key...'),
@@ -163,7 +163,13 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
}
return (
<div className='-ml-1.5 flex items-center gap-1'>
<div
className={
layout === 'card'
? 'flex items-center'
: '-ml-1.5 flex items-center gap-1'
}
>
{layout !== 'card' && (
<Tooltip>
<TooltipTrigger
@@ -185,71 +191,54 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</Tooltip>
)}
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleDirectTest}
disabled={isTesting}
aria-label={t('Test Connection')}
/>
}
>
{isTesting ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Gauge className='size-4' />
)}
</TooltipTrigger>
<TooltipContent>{t('Test Connection')}</TooltipContent>
</Tooltip>
{layout === 'card' && (
{layout !== 'card' && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={(e) => {
e.stopPropagation()
handleTest()
}}
aria-label={t('Test Channel Connection')}
onClick={handleDirectTest}
disabled={isTesting}
aria-label={t('Test Connection')}
/>
}
>
<PlugZap className='size-4' />
{isTesting ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Gauge className='size-4' />
)}
</TooltipTrigger>
<TooltipContent>{t('Test Channel Connection')}</TooltipContent>
<TooltipContent>{t('Test Connection')}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleToggleStatus}
disabled={isTogglingStatus}
aria-label={isEnabled ? t('Disable') : t('Enable')}
className={
isEnabled
? 'text-destructive hover:text-destructive'
: 'text-success hover:text-success'
}
/>
}
>
{statusIcon}
</TooltipTrigger>
<TooltipContent>
{isEnabled ? t('Disable') : t('Enable')}
</TooltipContent>
</Tooltip>
{layout !== 'card' && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleToggleStatus}
disabled={isTogglingStatus}
aria-label={isEnabled ? t('Disable') : t('Enable')}
className={
isEnabled
? 'text-destructive hover:text-destructive'
: 'text-success hover:text-success'
}
/>
}
>
{statusIcon}
</TooltipTrigger>
<TooltipContent>
{isEnabled ? t('Disable') : t('Enable')}
</TooltipContent>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger
@@ -281,6 +270,20 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<PlugZap size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{layout === 'card' && (
<DropdownMenuItem
disabled={isTogglingStatus}
onClick={() => void handleToggleStatus()}
className={
isEnabled
? 'text-destructive focus:text-destructive'
: 'text-success focus:text-success'
}
>
{isEnabled ? t('Disable') : t('Enable')}
<DropdownMenuShortcut>{statusIcon}</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{/* Query Balance */}
<DropdownMenuItem onClick={handleQueryBalance}>
@@ -229,7 +229,7 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
description={t(
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
)}
contentClassName='max-h-[90vh] max-w-2xl'
contentClassName='max-h-[90vh] sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
@@ -388,7 +388,7 @@ export function FetchModelsDialog({
t('Fetch available models from upstream')
)
}
contentClassName='max-w-3xl'
contentClassName='sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
@@ -249,7 +249,7 @@ export function MultiKeyManageDialog({
description={t(
'Manage multi-key status and configuration for this channel'
)}
contentClassName='flex max-h-[90vh] max-w-5xl flex-col'
contentClassName='flex max-h-[90vh] flex-col sm:max-w-5xl'
titleClassName='flex items-center gap-2'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
@@ -88,7 +88,7 @@ export function StatusCodeRiskDialog({
</>
}
description={t('High-risk status code retry risk disclaimer')}
contentClassName='max-w-lg'
contentClassName='sm:max-w-lg'
titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
@@ -195,7 +195,7 @@ export function TagBatchEditDialog({
<strong>{currentTag}</strong>
</>
}
contentClassName='max-w-2xl'
contentClassName='sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
+73 -85
View File
@@ -19,10 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { flexRender, type Row } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import {
DataTableCardDetails,
DataTableCardField,
} from '@/components/data-table'
import { DataTableCardField, DataTableCardRow } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import { formatQuota } from '@/lib/format'
@@ -82,108 +79,99 @@ export function ApiKeyCard(props: { row: Row<ApiKey> }) {
const visibleColumnIds = new Set(
props.row.getVisibleCells().map((cell) => cell.column.id)
)
const detailsCount = [
'status',
const hasMetaRows = [
'group',
'model_limits',
'allow_ips',
'quota',
'created_time',
'accessed_time',
'expired_time',
'actions',
].filter((columnId) => visibleColumnIds.has(columnId)).length
].some((columnId) => visibleColumnIds.has(columnId))
const hasDetailSections =
visibleColumnIds.has('model_limits') || visibleColumnIds.has('allow_ips')
return (
<>
<div className='grid grid-cols-2 gap-x-3 gap-y-2'>
{visibleColumnIds.has('name') && (
<DataTableCardField
label={t('Name')}
span={2}
contentMode='wrap'
valueClassName='font-medium'
>
{renderApiKeyCell(props.row, 'name')}
</DataTableCardField>
)}
{visibleColumnIds.has('key') && (
<DataTableCardField label={t('API Key')} span={2} contentMode='full'>
{renderApiKeyCell(props.row, 'key')}
</DataTableCardField>
)}
{visibleColumnIds.has('quota') && (
<DataTableCardField label={t('Quota')} span={2} contentMode='full'>
{apiKey.unlimited_quota ? (
<StatusBadge variant='neutral'>{t('Unlimited')}</StatusBadge>
) : (
<span className='font-medium tabular-nums'>
{formatQuota(apiKey.remain_quota)}
<span className='text-muted-foreground font-normal'>
{' / '}
{formatQuota(totalQuota)}
</span>
</span>
)}
</DataTableCardField>
<div className='flex min-w-0 flex-col'>
<div className='flex min-w-0 items-start justify-between gap-3'>
<div className='min-w-0 flex-1'>
{visibleColumnIds.has('name') && (
<div className='text-[15px] leading-tight font-semibold break-words'>
{renderApiKeyCell(props.row, 'name')}
</div>
)}
{visibleColumnIds.has('key') && (
<div className='mt-1.5 min-w-0'>
{renderApiKeyCell(props.row, 'key')}
</div>
)}
</div>
{visibleColumnIds.has('status') && (
<div className='shrink-0'>
{renderApiKeyCell(props.row, 'status')}
</div>
)}
</div>
{detailsCount > 0 && (
<DataTableCardDetails count={detailsCount}>
{visibleColumnIds.has('status') && (
<DataTableCardField label={t('Status')} contentMode='full'>
{renderApiKeyCell(props.row, 'status')}
</DataTableCardField>
{hasMetaRows && (
<div className='mt-3 space-y-0.5 border-t pt-3'>
{visibleColumnIds.has('quota') && (
<DataTableCardRow label={t('Quota')} contentMode='full'>
{apiKey.unlimited_quota ? (
<StatusBadge variant='neutral'>{t('Unlimited')}</StatusBadge>
) : (
<span className='font-medium tabular-nums'>
{formatQuota(apiKey.remain_quota)}
<span className='text-muted-foreground font-normal'>
{' / '}
{formatQuota(totalQuota)}
</span>
</span>
)}
</DataTableCardRow>
)}
{visibleColumnIds.has('group') && (
<DataTableCardField label={t('Group')} contentMode='full'>
<DataTableCardRow label={t('Group')} contentMode='full'>
{renderApiKeyCell(props.row, 'group')}
</DataTableCardField>
</DataTableCardRow>
)}
{visibleColumnIds.has('created_time') && (
<DataTableCardRow label={t('Created')} contentMode='full'>
{renderApiKeyCell(props.row, 'created_time')}
</DataTableCardRow>
)}
{visibleColumnIds.has('accessed_time') && (
<DataTableCardRow label={t('Last Used')} contentMode='full'>
{renderApiKeyCell(props.row, 'accessed_time')}
</DataTableCardRow>
)}
{visibleColumnIds.has('expired_time') && (
<DataTableCardRow label={t('Expires')} contentMode='full'>
{renderApiKeyCell(props.row, 'expired_time')}
</DataTableCardRow>
)}
</div>
)}
{hasDetailSections && (
<div className='mt-3 space-y-3 border-t pt-3'>
{visibleColumnIds.has('model_limits') && (
<DataTableCardField label={t('Models')} span={2} contentMode='full'>
<DataTableCardField label={t('Models')} contentMode='full'>
<ApiKeyModels apiKey={apiKey} />
</DataTableCardField>
)}
{visibleColumnIds.has('allow_ips') && (
<DataTableCardField
label={t('IP Restriction')}
span={2}
contentMode='full'
>
<DataTableCardField label={t('IP Restriction')} contentMode='full'>
<ApiKeyIpRestrictions apiKey={apiKey} />
</DataTableCardField>
)}
{visibleColumnIds.has('created_time') && (
<DataTableCardField label={t('Created')} contentMode='full'>
{renderApiKeyCell(props.row, 'created_time')}
</DataTableCardField>
)}
{visibleColumnIds.has('accessed_time') && (
<DataTableCardField label={t('Last Used')} contentMode='full'>
{renderApiKeyCell(props.row, 'accessed_time')}
</DataTableCardField>
)}
{visibleColumnIds.has('expired_time') && (
<DataTableCardField
label={t('Expires')}
span={2}
contentMode='full'
>
{renderApiKeyCell(props.row, 'expired_time')}
</DataTableCardField>
)}
{visibleColumnIds.has('actions') && (
<DataTableCardField
label={t('Operations')}
span={2}
contentMode='full'
>
{renderApiKeyCell(props.row, 'actions')}
</DataTableCardField>
)}
</DataTableCardDetails>
</div>
)}
</>
{visibleColumnIds.has('actions') && (
<div className='mt-3 flex justify-end border-t pt-2'>
{renderApiKeyCell(props.row, 'actions')}
</div>
)}
</div>
)
}
@@ -260,9 +260,7 @@ export function ApiKeysMutateDrawer({
}
}}
>
<SheetContent
className={sideDrawerContentClassName('max-w-none sm:!max-w-[620px]')}
>
<SheetContent className={sideDrawerContentClassName('sm:max-w-[620px]')}>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>
{isUpdate ? t('Update API Key') : t('Create API Key')}
@@ -41,7 +41,7 @@ export function DescriptionDialog({
onOpenChange={onOpenChange}
title={modelName}
description={t('Model Description')}
contentClassName='max-w-2xl'
contentClassName='sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
>
@@ -118,7 +118,7 @@ export function MissingModelsDialog({
description={t(
'Models that are being used but not configured in the system'
)}
contentClassName='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4'
contentClassName='flex max-h-[85vh] flex-col gap-3 p-4 sm:max-w-2xl'
headerClassName='flex-shrink-0 text-start'
contentHeight='min(74vh, 760px)'
bodyClassName='space-y-4'
@@ -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>
)
}
@@ -18,7 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Tabs, TabsList, TabsTrigger } from '@/components/design-system/tabs'
import { PublicPageHeader } from '@/components/layout'
import type { RankingPeriod } from '../types'
@@ -34,60 +35,45 @@ type RankingsHeroProps = {
onPeriodChange: (period: RankingPeriod) => void
}
/**
* Hero strip for the rankings page. Intentionally minimal — title +
* subtitle + period tabs only.
*/
export function RankingsHero(props: RankingsHeroProps) {
const { t } = useTranslation()
return (
<section className='space-y-5'>
<div className='space-y-2'>
<h1 className='text-[clamp(1.75rem,4vw,2.5rem)] leading-[1.15] font-bold tracking-tight'>
{t('Rankings')}
</h1>
<p className='text-muted-foreground/80 max-w-2xl text-sm'>
{t(
'Discover the most-used models and rising vendors on the platform, updated from live usage data.'
)}
</p>
</div>
{/* Underline tabs for period — clean and unobtrusive. */}
<div
role='tablist'
aria-label={t('Period')}
className='border-border/60 flex items-center border-b'
<PublicPageHeader
title={t('Rankings')}
description={t(
'Discover the most-used models and rising vendors on the platform, updated from live usage data.'
)}
>
<Tabs
value={props.period}
onValueChange={(value) => {
if (
value === 'today' ||
value === 'week' ||
value === 'month' ||
value === 'year'
) {
props.onPeriodChange(value)
}
}}
>
{PERIODS.map((p) => {
const isActive = props.period === p.id
return (
<button
key={p.id}
role='tab'
type='button'
aria-selected={isActive}
onClick={() => props.onPeriodChange(p.id)}
className={cn(
'focus-visible:ring-ring/40 relative -mb-px rounded-sm px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none',
isActive
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
<TabsList
variant='line'
aria-label={t('Period')}
className='w-full justify-start gap-6 overflow-x-auto overflow-y-hidden border-b p-0'
>
{PERIODS.map((period) => (
<TabsTrigger
key={period.id}
value={period.id}
className='flex-none px-0.5 pb-3'
>
{t(p.labelKey)}
<span
aria-hidden
className={cn(
'bg-foreground absolute inset-x-3 -bottom-px h-[2px] rounded-full transition-opacity',
isActive ? 'opacity-100' : 'opacity-0'
)}
/>
</button>
)
})}
</div>
</section>
{t(period.labelKey)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</PublicPageHeader>
)
}
+44 -55
View File
@@ -17,10 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useNavigate, useSearch } from '@tanstack/react-router'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { PublicLayout } from '@/components/layout'
import { PageTransition } from '@/components/page-transition'
import { PublicLayout, PublicPageShell } from '@/components/layout'
import { Skeleton } from '@/components/ui/skeleton'
import {
@@ -32,14 +32,14 @@ import {
import { useRankings } from './hooks/use-rankings'
import type { RankingPeriod } from './types'
const VALID_PERIODS: RankingPeriod[] = ['today', 'week', 'month', 'year']
const VALID_PERIODS = new Set<RankingPeriod>(['today', 'week', 'month', 'year'])
export function Rankings() {
const { t } = useTranslation()
const search = useSearch({ from: '/rankings/' })
const navigate = useNavigate()
const period: RankingPeriod = VALID_PERIODS.includes(
const period: RankingPeriod = VALID_PERIODS.has(
search.period as RankingPeriod
)
? (search.period as RankingPeriod)
@@ -55,59 +55,48 @@ export function Rankings() {
})
}
let rankingsBody: ReactNode
if (rankingsQuery.isLoading) {
rankingsBody = <RankingsLoading />
} else if (!snapshot) {
rankingsBody = (
<RankingsError
message={
rankingsQuery.error instanceof Error
? rankingsQuery.error.message
: t('Unable to load rankings data')
}
/>
)
} else {
rankingsBody = (
<div className='space-y-8'>
<ModelsSection
history={snapshot.models_history}
rows={snapshot.models}
period={period}
/>
<MarketShareSection
history={snapshot.vendor_share_history}
rows={snapshot.vendors}
period={period}
/>
<PulseSection
movers={snapshot.top_movers}
droppers={snapshot.top_droppers}
/>
</div>
)
}
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-[1280px] space-y-8 px-3 pt-16 pb-10 sm:px-6 sm:pt-20 sm:pb-12 xl:px-8'>
<RankingsHero period={period} onPeriodChange={handlePeriodChange} />
{rankingsQuery.isLoading ? (
<RankingsLoading />
) : !snapshot ? (
<RankingsError
message={
rankingsQuery.error instanceof Error
? rankingsQuery.error.message
: t('Unable to load rankings data')
}
/>
) : (
<>
<ModelsSection
history={snapshot.models_history}
rows={snapshot.models}
period={period}
/>
<MarketShareSection
history={snapshot.vendor_share_history}
rows={snapshot.vendors}
period={period}
/>
<PulseSection
movers={snapshot.top_movers}
droppers={snapshot.top_droppers}
/>
</>
)}
</PageTransition>
</div>
<PublicPageShell>
<RankingsHero period={period} onPeriodChange={handlePeriodChange} />
{rankingsBody}
</PublicPageShell>
</PublicLayout>
)
}
@@ -440,7 +440,7 @@ export function AnnouncementsSection({
description={t(
'Create or update system announcements for the dashboard'
)}
contentClassName='max-w-2xl'
contentClassName='sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
@@ -322,7 +322,7 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
onOpenChange={setShowDialog}
title={editingFaq ? t('Edit FAQ') : t('Add FAQ')}
description={t('Create or update frequently asked questions for users')}
contentClassName='max-w-2xl'
contentClassName='sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
@@ -245,7 +245,7 @@ export function RuleEditorDialog(props: Props) {
open={props.open}
onOpenChange={props.onOpenChange}
title={isEdit ? t('Edit Rule') : t('Add Rule')}
contentClassName='max-w-2xl'
contentClassName='sm:max-w-2xl'
contentHeight='auto'
bodyClassName='pr-2'
footer={
@@ -55,7 +55,7 @@ export function ConflictConfirmDialog({
const { t } = useTranslation()
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className='max-w-4xl'>
<AlertDialogContent className='sm:max-w-4xl'>
<AlertDialogHeader>
<AlertDialogTitle>{t('Confirm Billing Conflicts')}</AlertDialogTitle>
<AlertDialogDescription>
@@ -25,8 +25,8 @@ import {
import { useTranslation } from 'react-i18next'
import {
DataTableCardDetails,
DataTableCardField,
DataTableCardRow,
MobileCardList,
} from '@/components/data-table'
import { cn } from '@/lib/utils'
@@ -73,76 +73,76 @@ function orderCardCells<TData>(
})
}
function CardCellField<TData>(props: {
cell: Cell<TData, unknown>
hideLabel?: boolean
className?: string
valueClassName?: string
}) {
const meta = props.cell.column.columnDef.meta
return (
<DataTableCardField
label={props.hideLabel ? undefined : getCardLabel(props.cell)}
contentMode={meta?.contentMode}
span={meta?.cardSpan}
className={props.className}
valueClassName={props.valueClassName}
>
{flexRender(props.cell.column.columnDef.cell, props.cell.getContext())}
</DataTableCardField>
)
function isWideField<TData>(cell: Cell<TData, unknown>): boolean {
const meta = cell.column.columnDef.meta
return meta?.cardSpan === 2 || meta?.contentMode === 'summary'
}
function UsageLogCard<TData>(props: { cells: Cell<TData, unknown>[] }) {
const titleCell = props.cells.find((cell) => getCardRole(cell) === 'title')
const badgeCell = props.cells.find((cell) => getCardRole(cell) === 'badge')
const primaryCells = orderCardCells(
props.cells.filter((cell) => getCardRole(cell) === 'primary')
)
const secondaryCells = orderCardCells(
props.cells.filter((cell) => getCardRole(cell) === 'secondary')
const bodyCells = orderCardCells(
props.cells.filter(
(cell) =>
getCardRole(cell) !== 'title' &&
getCardRole(cell) !== 'badge' &&
getCardRole(cell) !== 'hidden'
)
)
const rowCells = bodyCells.filter((cell) => !isWideField(cell))
const wideCells = bodyCells.filter((cell) => isWideField(cell))
return (
<>
<div className='flex min-w-0 flex-col'>
{(titleCell || badgeCell) && (
<div className='flex min-w-0 items-start justify-between gap-3'>
{titleCell && (
<CardCellField
cell={titleCell}
hideLabel
className='min-w-0 flex-1'
valueClassName='font-medium'
/>
<div className='min-w-0 flex-1 text-[15px] leading-tight font-semibold break-words'>
{flexRender(
titleCell.column.columnDef.cell,
titleCell.getContext()
)}
</div>
)}
{badgeCell && (
<CardCellField
cell={badgeCell}
hideLabel
className='max-w-1/2 shrink text-right'
valueClassName='flex justify-end text-right tabular-nums'
/>
<div className='max-w-1/2 shrink text-right tabular-nums'>
{flexRender(
badgeCell.column.columnDef.cell,
badgeCell.getContext()
)}
</div>
)}
</div>
)}
{primaryCells.length > 0 && (
<div className='mt-2 grid grid-cols-2 gap-x-3 gap-y-2'>
{primaryCells.map((cell) => (
<CardCellField key={cell.id} cell={cell} />
{rowCells.length > 0 && (
<div className='mt-3 space-y-0.5 border-t pt-3'>
{rowCells.map((cell) => (
<DataTableCardRow
key={cell.id}
label={getCardLabel(cell)}
contentMode={cell.column.columnDef.meta?.contentMode}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataTableCardRow>
))}
</div>
)}
{secondaryCells.length > 0 && (
<DataTableCardDetails count={secondaryCells.length}>
{secondaryCells.map((cell) => (
<CardCellField key={cell.id} cell={cell} />
{wideCells.length > 0 && (
<div className='mt-3 space-y-3 border-t pt-3'>
{wideCells.map((cell) => (
<DataTableCardField
key={cell.id}
label={getCardLabel(cell)}
contentMode={cell.column.columnDef.meta?.contentMode ?? 'full'}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataTableCardField>
))}
</DataTableCardDetails>
</div>
)}
</>
</div>
)
}
+3
View File
@@ -395,6 +395,7 @@
"API Key mode: use APIKey|Region": "API Key mode: use APIKey|Region",
"API Key updated successfully": "API Key updated successfully",
"API Keys": "API Keys",
"API pricing": "API pricing",
"API Private Key": "API Private Key",
"API Requests": "API Requests",
"API secret": "API secret",
@@ -2662,6 +2663,7 @@
"Models": "Models",
"Models *": "Models *",
"Models & Groups": "Models & Groups",
"Models & pricing": "Models & pricing",
"Models & Routing": "Models & Routing",
"Models appended successfully": "Models appended successfully",
"Models are required": "Models are required",
@@ -4379,6 +4381,7 @@
"Text or array of texts to embed": "Text or array of texts to embed",
"Text Output": "Text Output",
"Text to Video": "Text to Video",
"Text tokens": "Text tokens",
"The admin configured three groups and one special ratio rule:": "The admin configured three groups and one special ratio rule:",
"The admin wants vip users to pay even less when they use premium. That needs an override rule: in the override matrix, set the cell at row vip, column premium to 0.3.": "The admin wants vip users to pay even less when they use premium. That needs an override rule: in the override matrix, set the cell at row vip, column premium to 0.3.",
"The administrator account is already initialized. You can keep your existing credentials and continue to the next step.": "The administrator account is already initialized. You can keep your existing credentials and continue to the next step.",
+3
View File
@@ -395,6 +395,7 @@
"API Key mode: use APIKey|Region": "Mode clé API : utiliser APIKey|Region",
"API Key updated successfully": "Clé API mise à jour avec succès",
"API Keys": "Clés API",
"API pricing": "Tarification de lAPI",
"API Private Key": "Clé privée de l'API",
"API Requests": "Requêtes API",
"API secret": "Secret API",
@@ -2662,6 +2663,7 @@
"Models": "Modèles",
"Models *": "Modèles *",
"Models & Groups": "Modèles & Groupes",
"Models & pricing": "Modèles et tarification",
"Models & Routing": "Modèles et routage",
"Models appended successfully": "Modèles ajoutés avec succès",
"Models are required": "Les modèles sont requis",
@@ -4379,6 +4381,7 @@
"Text or array of texts to embed": "Texte ou tableau de textes à vectoriser",
"Text Output": "Sortie texte",
"Text to Video": "Texte vers vidéo",
"Text tokens": "Jetons texte",
"The admin configured three groups and one special ratio rule:": "Ladministrateur a configuré trois groupes et une règle de taux spécial :",
"The admin wants vip users to pay even less when they use premium. That needs an override rule: in the override matrix, set the cell at row vip, column premium to 0.3.": "Ladministrateur veut que les utilisateurs vip paient encore moins lorsquils utilisent premium. Il faut une règle de remplacement : dans la matrice, définissez la cellule ligne vip, colonne premium à 0,3.",
"The administrator account is already initialized. You can keep your existing credentials and continue to the next step.": "Le compte administrateur est déjà initialisé. Vous pouvez conserver vos identifiants existants et passer à l'étape suivante.",
+3
View File
@@ -395,6 +395,7 @@
"API Key mode: use APIKey|Region": "APIキーモード: use APIKey | Region",
"API Key updated successfully": "APIキーが正常に更新されました",
"API Keys": "APIキー",
"API pricing": "API 料金",
"API Private Key": "API 秘密鍵",
"API Requests": "APIリクエスト",
"API secret": "APIシークレット",
@@ -2662,6 +2663,7 @@
"Models": "モデル",
"Models *": "モデル *",
"Models & Groups": "モデルとグループ",
"Models & pricing": "モデルと料金",
"Models & Routing": "モデルとルーティング",
"Models appended successfully": "モデルが正常に追加されました",
"Models are required": "モデルが必要です",
@@ -4379,6 +4381,7 @@
"Text or array of texts to embed": "ベクトル化するテキストまたは配列",
"Text Output": "テキスト出力",
"Text to Video": "テキストから動画",
"Text tokens": "テキストトークン",
"The admin configured three groups and one special ratio rule:": "管理者は3つのグループと1つの特別倍率ルールを設定しました:",
"The admin wants vip users to pay even less when they use premium. That needs an override rule: in the override matrix, set the cell at row vip, column premium to 0.3.": "管理者は vip ユーザーが premium を使うときにさらに安くしたいと考えています。それには上書きルールが必要です:上書きマトリクスで「行 vip、列 premium」のセルに 0.3 を設定します。",
"The administrator account is already initialized. You can keep your existing credentials and continue to the next step.": "管理者アカウントはすでに初期化されています。既存の認証情報を保持して、次のステップに進むことができます。",
+3
View File
@@ -395,6 +395,7 @@
"API Key mode: use APIKey|Region": "Режим API Key: use APIKey|Region",
"API Key updated successfully": "API ключ успешно обновлен",
"API Keys": "Ключи API",
"API pricing": "Тарифы API",
"API Private Key": "Секретный ключ API",
"API Requests": "Запросы API",
"API secret": "Секрет API",
@@ -2662,6 +2663,7 @@
"Models": "Модели",
"Models *": "Модели *",
"Models & Groups": "Модели и группы",
"Models & pricing": "Модели и тарифы",
"Models & Routing": "Модели и маршрутизация",
"Models appended successfully": "Модели успешно добавлены",
"Models are required": "Требуются модели",
@@ -4379,6 +4381,7 @@
"Text or array of texts to embed": "Текст или массив текстов для векторизации",
"Text Output": "Текстовый выход",
"Text to Video": "Текст в видео",
"Text tokens": "Текстовые токены",
"The admin configured three groups and one special ratio rule:": "Администратор настроил три группы и одно правило особого коэффициента:",
"The admin wants vip users to pay even less when they use premium. That needs an override rule: in the override matrix, set the cell at row vip, column premium to 0.3.": "Администратор хочет, чтобы пользователи vip платили ещё меньше при использовании premium. Для этого нужно правило переопределения: в матрице задайте ячейку на пересечении строки vip и столбца premium равной 0,3.",
"The administrator account is already initialized. You can keep your existing credentials and continue to the next step.": "Учетная запись администратора уже инициализирована. Вы можете сохранить существующие учетные данные и перейти к следующему шагу.",
+3
View File
@@ -395,6 +395,7 @@
"API Key mode: use APIKey|Region": "Chế độ khóa API: sử dụng APIKey|Region",
"API Key updated successfully": "API Key đã được cập nhật thành công",
"API Keys": "Khóa API",
"API pricing": "Bảng giá API",
"API Private Key": "Khóa riêng API",
"API Requests": "Yêu cầu API",
"API secret": "Bí mật API",
@@ -2662,6 +2663,7 @@
"Models": "Mô hình",
"Models *": "Các mô hình *",
"Models & Groups": "Mô hình & Nhóm",
"Models & pricing": "Mô hình và giá",
"Models & Routing": "Mô hình & định tuyến",
"Models appended successfully": "Đã thêm mô hình thành công",
"Models are required": "Các mô hình được yêu cầu",
@@ -4379,6 +4381,7 @@
"Text or array of texts to embed": "Văn bản hoặc mảng văn bản cần vector hoá",
"Text Output": "Đầu ra văn bản",
"Text to Video": "Văn bản sang video",
"Text tokens": "Token văn bản",
"The admin configured three groups and one special ratio rule:": "Quản trị viên đã cấu hình ba nhóm và một quy tắc hệ số đặc biệt:",
"The admin wants vip users to pay even less when they use premium. That needs an override rule: in the override matrix, set the cell at row vip, column premium to 0.3.": "Quản trị viên muốn người dùng vip trả ít hơn nữa khi dùng premium. Điều đó cần một quy tắc ghi đè: trong ma trận ghi đè, đặt ô tại hàng vip, cột premium thành 0.3.",
"The administrator account is already initialized. You can keep your existing credentials and continue to the next step.": "Tài khoản quản trị viên đã được khởi tạo. Bạn có thể giữ nguyên thông tin đăng nhập hiện có của mình và tiếp tục sang bước tiếp theo.",
+4 -1
View File
@@ -395,6 +395,7 @@
"API Key mode: use APIKey|Region": "API Key 模式:使用 APIKey|Region",
"API Key updated successfully": "API 金鑰更新成功",
"API Keys": "API 金鑰",
"API pricing": "API 定價",
"API Private Key": "API 私鑰",
"API Requests": "API 請求",
"API secret": "API 密鑰",
@@ -2002,7 +2003,7 @@
"footer.columns.related.links.oneApi": "One API",
"footer.columns.related.title": "相關項目",
"footer.defaultCopyright": "版權所有。",
"footer.new\u0061pi.projectAttributionSuffix": "版權所有,由項目貢獻者設計與開發。",
"footer.newapi.projectAttributionSuffix": "版權所有,由項目貢獻者設計與開發。",
"For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "對於 2025 年 5 月 10 日之後新增的渠道,在部署時無需從模型名稱中移除 \".\"",
"For private deployments, format: https://fastgpt.run/api/openapi": "對於私有部署,格式為:https://fastgpt.run/api/openapi",
"Force a syntactically valid JSON response": "強制返回語法合法的 JSON",
@@ -2662,6 +2663,7 @@
"Models": "模型",
"Models *": "模型 *",
"Models & Groups": "模型與分組",
"Models & pricing": "模型與定價",
"Models & Routing": "模型與路由",
"Models appended successfully": "模型已追加成功",
"Models are required": "需要模型",
@@ -4379,6 +4381,7 @@
"Text or array of texts to embed": "需要向量化的文字或文字陣列",
"Text Output": "文字輸出",
"Text to Video": "文生影片",
"Text tokens": "文字 Token",
"The admin configured three groups and one special ratio rule:": "管理員設定了三個分組和一條特殊倍率規則:",
"The admin wants vip users to pay even less when they use premium. That needs an override rule: in the override matrix, set the cell at row vip, column premium to 0.3.": "管理員希望 vip 用戶使用 premium 時價格更低。這就需要一條覆蓋規則:在覆蓋矩陣中,把「行 vip、列 premium」的單元格填成 0.3。",
"The administrator account is already initialized. You can keep your existing credentials and continue to the next step.": "管理員用戶已初始化。您可以保留現有憑證並繼續下一步。",
+3
View File
@@ -395,6 +395,7 @@
"API Key mode: use APIKey|Region": "API Key 模式:使用 APIKey|Region",
"API Key updated successfully": "API 密钥更新成功",
"API Keys": "API 密钥",
"API pricing": "API 定价",
"API Private Key": "API 私钥",
"API Requests": "API 请求",
"API secret": "API 秘钥",
@@ -2662,6 +2663,7 @@
"Models": "模型",
"Models *": "模型 *",
"Models & Groups": "模型与分组",
"Models & pricing": "模型与定价",
"Models & Routing": "模型与路由",
"Models appended successfully": "模型已追加成功",
"Models are required": "需要模型",
@@ -4379,6 +4381,7 @@
"Text or array of texts to embed": "需要向量化的文本或文本数组",
"Text Output": "文字输出",
"Text to Video": "文生视频",
"Text tokens": "文本 Token",
"The admin configured three groups and one special ratio rule:": "管理员配置了三个分组和一条特殊倍率规则:",
"The admin wants vip users to pay even less when they use premium. That needs an override rule: in the override matrix, set the cell at row vip, column premium to 0.3.": "管理员希望 vip 用户使用 premium 时价格更低。这就需要一条覆盖规则:在覆盖矩阵中,把「行 vip、列 premium」的单元格填成 0.3。",
"The administrator account is already initialized. You can keep your existing credentials and continue to the next step.": "管理员账户已初始化。您可以保留现有凭据并继续下一步。",
+134
View File
@@ -0,0 +1,134 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
/*
* Local @font-face declarations for Public Sans + Lora.
*
* Why not `@import '@fontsource-variable/...'` directly?
* Fontsource ships `font-display: swap`, which paints system fallbacks first
* and then swaps in the webfont — that metric change is the visible "font
* jump" (text suddenly larger/smaller). `optional` keeps the first painted
* face for the whole page lifetime: cached visits get the webfont with no
* flash; slow first visits stay on the system stack instead of swapping mid-
* session. Subsets match the upstream packages (minus Lora math/symbols,
* which are unused in UI chrome and only added extra late swaps).
*/
/* ── Public Sans Variable ─────────────────────────────────────────────── */
@font-face {
font-family: 'Public Sans Variable';
font-style: normal;
font-display: optional;
font-weight: 100 900;
src: url('../../node_modules/@fontsource-variable/public-sans/files/public-sans-vietnamese-wght-normal.woff2')
format('woff2-variations');
unicode-range:
U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
U+1EA0-1EF9, U+20AB;
}
@font-face {
font-family: 'Public Sans Variable';
font-style: normal;
font-display: optional;
font-weight: 100 900;
src: url('../../node_modules/@fontsource-variable/public-sans/files/public-sans-latin-ext-wght-normal.woff2')
format('woff2-variations');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Public Sans Variable';
font-style: normal;
font-display: optional;
font-weight: 100 900;
src: url('../../node_modules/@fontsource-variable/public-sans/files/public-sans-latin-wght-normal.woff2')
format('woff2-variations');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
U+2215, U+FEFF, U+FFFD;
}
/* ── Lora Variable ────────────────────────────────────────────────────── */
@font-face {
font-family: 'Lora Variable';
font-style: normal;
font-display: optional;
font-weight: 400 700;
src: url('../../node_modules/@fontsource-variable/lora/files/lora-cyrillic-ext-wght-normal.woff2')
format('woff2-variations');
unicode-range:
U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@font-face {
font-family: 'Lora Variable';
font-style: normal;
font-display: optional;
font-weight: 400 700;
src: url('../../node_modules/@fontsource-variable/lora/files/lora-cyrillic-wght-normal.woff2')
format('woff2-variations');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'Lora Variable';
font-style: normal;
font-display: optional;
font-weight: 400 700;
src: url('../../node_modules/@fontsource-variable/lora/files/lora-vietnamese-wght-normal.woff2')
format('woff2-variations');
unicode-range:
U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
U+1EA0-1EF9, U+20AB;
}
@font-face {
font-family: 'Lora Variable';
font-style: normal;
font-display: optional;
font-weight: 400 700;
src: url('../../node_modules/@fontsource-variable/lora/files/lora-latin-ext-wght-normal.woff2')
format('woff2-variations');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Lora Variable';
font-style: normal;
font-display: optional;
font-weight: 400 700;
src: url('../../node_modules/@fontsource-variable/lora/files/lora-latin-wght-normal.woff2')
format('woff2-variations');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
U+2215, U+FEFF, U+FFFD;
}
+7 -8
View File
@@ -19,13 +19,9 @@ For commercial licensing, please contact support@quantumnous.com
@import 'tailwindcss';
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@import '@fontsource-variable/public-sans';
/* Editorial serif (Lora) backing the `serif` font axis and the Anthropic
* preset's default typography. See `--font-serif` in theme.css for the
* full Latin + CJK fallback stack and `theme-presets.css` for the cascade
* that activates it. Loaded globally so font-switching is instantaneous
* with no FOUT once the variable is fetched. */
@import '@fontsource-variable/lora';
/* Public Sans + Lora with `font-display: optional` (see fonts.css). Avoids
* the mid-session size jump from Fontsource's default `swap` policy. */
@import './fonts.css';
@import './theme.css';
@import './theme-presets.css';
@@ -54,7 +50,10 @@ For commercial licensing, please contact support@quantumnous.com
scrollbar-color: var(--border) transparent;
}
html {
@apply overflow-x-hidden font-sans;
/* Inherit the active body face from `--font-body` (set on body). Avoid
* `font-sans` here — it pinned Public Sans even when the serif axis was
* active, and fought the body cascade during theme boot. */
@apply overflow-x-hidden;
}
body {
@apply bg-background text-foreground has-[div[data-variant='inset']]:bg-sidebar min-h-svh w-full;