♻️ 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:
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)}
|
||||
>
|
||||
|
||||
+36
-29
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+26
-34
@@ -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={
|
||||
|
||||
+1
-1
@@ -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={
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+1
-1
@@ -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
@@ -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'
|
||||
>
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
+31
-40
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+36
-50
@@ -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
@@ -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={
|
||||
|
||||
+1
-1
@@ -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={
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+48
-48
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Vendored
+3
@@ -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.",
|
||||
|
||||
Vendored
+3
@@ -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 l’API",
|
||||
"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:": "L’administrateur 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.": "L’administrateur veut que les utilisateurs vip paient encore moins lorsqu’ils 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.",
|
||||
|
||||
Vendored
+3
@@ -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.": "管理者アカウントはすでに初期化されています。既存の認証情報を保持して、次のステップに進むことができます。",
|
||||
|
||||
Vendored
+3
@@ -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.": "Учетная запись администратора уже инициализирована. Вы можете сохранить существующие учетные данные и перейти к следующему шагу.",
|
||||
|
||||
Vendored
+3
@@ -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
@@ -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.": "管理員用戶已初始化。您可以保留現有憑證並繼續下一步。",
|
||||
|
||||
Vendored
+3
@@ -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.": "管理员账户已初始化。您可以保留现有凭据并继续下一步。",
|
||||
|
||||
Vendored
+134
@@ -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;
|
||||
}
|
||||
Vendored
+7
-8
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user