diff --git a/web/default/src/components/data-table/core/badge-list-cell-context.ts b/web/default/src/components/data-table/core/badge-list-cell-context.ts new file mode 100644 index 00000000..bb3b0c85 --- /dev/null +++ b/web/default/src/components/data-table/core/badge-list-cell-context.ts @@ -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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { createContext } from 'react' + +export type BadgeListCellDisplay = 'compact' | 'full' + +export const BadgeListCellDisplayContext = + createContext('compact') diff --git a/web/default/src/components/data-table/core/badge-list-cell.tsx b/web/default/src/components/data-table/core/badge-list-cell.tsx index 525a01e6..0795c65d 100644 --- a/web/default/src/components/data-table/core/badge-list-cell.tsx +++ b/web/default/src/components/data-table/core/badge-list-cell.tsx @@ -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 - } + if (display === 'full') { + return ( + item} + className='flex-wrap overflow-visible' + /> + ) + } + const showTooltip = items.length > max return ( diff --git a/web/default/src/components/data-table/index.ts b/web/default/src/components/data-table/index.ts index 6569f9fc..a4737171 100644 --- a/web/default/src/components/data-table/index.ts +++ b/web/default/src/components/data-table/index.ts @@ -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' diff --git a/web/default/src/components/data-table/layout/card-field.tsx b/web/default/src/components/data-table/layout/card-field.tsx index 072b5562..392c6542 100644 --- a/web/default/src/components/data-table/layout/card-field.tsx +++ b/web/default/src/components/data-table/layout/card-field.tsx @@ -16,20 +16,15 @@ along with this program. If not, see . 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 && ( -
+
{label}
)} @@ -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 ( - - - } - > - {open ? t('Less') : t('More')} - {!open && count != null && count > 0 && ( - ({count}) + + {label} + +
- - -
- {children} -
-
- + > + {children ?? -} +
+
) } diff --git a/web/default/src/components/data-table/layout/card-grid.tsx b/web/default/src/components/data-table/layout/card-grid.tsx index bf8308c1..d2f5ca0c 100644 --- a/web/default/src/components/data-table/layout/card-grid.tsx +++ b/web/default/src/components/data-table/layout/card-grid.tsx @@ -169,7 +169,7 @@ export function DataTableCardGrid(props: DataTableCardGridProps) { 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) )} > diff --git a/web/default/src/components/data-table/layout/card-row-content.tsx b/web/default/src/components/data-table/layout/card-row-content.tsx index e63e25c0..8b889bcc 100644 --- a/web/default/src/components/data-table/layout/card-row-content.tsx +++ b/web/default/src/components/data-table/layout/card-row-content.tsx @@ -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( }) } -function CardFields({ cells }: { cells: Cell[] }) { - return cells.map((cell) => { - const meta = cell.column.columnDef.meta - return ( - - {renderCellContent(cell)} - - ) - }) +function isWideField(cell: Cell): 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(props: { row: Row @@ -74,67 +62,84 @@ export function CardRowContent(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 ( - <> +
{props.compact && (titleCell || badgeCell) && (
-
+
{titleCell ? renderCellContent(titleCell) : null}
{badgeCell && ( - +
{renderCellContent(badgeCell)} - +
)}
)} - {fieldCells.length > 0 && ( -
- + {!props.compact && ( +
+ {bodyCells.map((cell) => { + const meta = cell.column.columnDef.meta + return ( + + {renderCellContent(cell)} + + ) + })}
)} - {secondaryCells.length > 0 && ( - - - + {props.compact && rowCells.length > 0 && ( +
+ {rowCells.map((cell) => ( + + {renderCellContent(cell)} + + ))} +
+ )} + + {props.compact && wideCells.length > 0 && ( +
+ {wideCells.map((cell) => ( + + {renderCellContent(cell)} + + ))} +
)} {actionsCell && ( -
+
{renderCellContent(actionsCell)}
)} - +
) } diff --git a/web/default/src/components/data-table/layout/mobile-card-list.tsx b/web/default/src/components/data-table/layout/mobile-card-list.tsx index 98d0da07..f7641e9d 100644 --- a/web/default/src/components/data-table/layout/mobile-card-list.tsx +++ b/web/default/src/components/data-table/layout/mobile-card-list.tsx @@ -150,7 +150,7 @@ export function MobileCardList(props: MobileCardListProps) {
diff --git a/web/default/src/components/data-table/toolbar/filter-panel.tsx b/web/default/src/components/data-table/toolbar/filter-panel.tsx index 74c97f32..05b29754 100644 --- a/web/default/src/components/data-table/toolbar/filter-panel.tsx +++ b/web/default/src/components/data-table/toolbar/filter-panel.tsx @@ -55,6 +55,7 @@ export interface DataTableFilterPanelProps { searchLoading?: boolean onReset: () => void onSearch?: () => void + inlineActions?: boolean className?: string } @@ -144,6 +145,35 @@ export function DataTableFilterPanel( ) : null + const desktopActions = ( +
+ {props.actionStart} + + {props.onSearch && ( + + )} + {props.viewToggle} + {viewOptions} +
+ ) + if (isMobile && props.mobilePinnedFilters != null) { return ( @@ -264,6 +294,7 @@ export function DataTableFilterPanel( {advancedToggle}
)} + {props.inlineActions && desktopActions}
{advancedOpen && props.advancedFilters && ( @@ -272,36 +303,12 @@ export function DataTableFilterPanel(
)} -
- {props.stats} -
- {props.actionStart} - - {props.onSearch && ( - - )} - {props.viewToggle} - {viewOptions} + {(!props.inlineActions || props.stats != null) && ( +
+ {props.stats} + {!props.inlineActions && desktopActions}
-
+ )}
) } diff --git a/web/default/src/components/data-table/toolbar/toolbar.tsx b/web/default/src/components/data-table/toolbar/toolbar.tsx index f69f4f05..51c3cc43 100644 --- a/web/default/src/components/data-table/toolbar/toolbar.tsx +++ b/web/default/src/components/data-table/toolbar/toolbar.tsx @@ -276,8 +276,11 @@ export function DataTableToolbar(props: DataTableToolbarProps) { 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(props: DataTableToolbarProps) { searchLoading={props.searchLoading} onReset={handleReset} onSearch={hasSearch ? props.onSearch : undefined} + inlineActions={inlineActions} className={props.className} /> ) diff --git a/web/default/src/components/design-system/tabs.tsx b/web/default/src/components/design-system/tabs.tsx index ae7c2ed3..66b463b4 100644 --- a/web/default/src/components/design-system/tabs.tsx +++ b/web/default/src/components/design-system/tabs.tsx @@ -28,13 +28,17 @@ import { cn } from '@/lib/utils' function TabsList({ className, + variant = 'default', ...props }: React.ComponentProps) { return ( : null} 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 ) diff --git a/web/default/src/components/layout/components/public-page-header.tsx b/web/default/src/components/layout/components/public-page-header.tsx new file mode 100644 index 00000000..9bfccd77 --- /dev/null +++ b/web/default/src/components/layout/components/public-page-header.tsx @@ -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 . + +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 ( + + {props.children} + + ) +} + +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 ( +
+
+

{props.title}

+ {props.description != null && props.description !== '' && ( +

+ {props.description} +

+ )} +
+ {props.children} +
+ ) +} diff --git a/web/default/src/components/layout/index.ts b/web/default/src/components/layout/index.ts index fe5ae178..db60864e 100644 --- a/web/default/src/components/layout/index.ts +++ b/web/default/src/components/layout/index.ts @@ -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' diff --git a/web/default/src/components/risk-acknowledgement-dialog.tsx b/web/default/src/components/risk-acknowledgement-dialog.tsx index 5ad49705..c45e9c76 100644 --- a/web/default/src/components/risk-acknowledgement-dialog.tsx +++ b/web/default/src/components/risk-acknowledgement-dialog.tsx @@ -183,7 +183,7 @@ export function RiskAcknowledgementDialog({ diff --git a/web/default/src/components/ui/alert-dialog.tsx b/web/default/src/components/ui/alert-dialog.tsx index c3a3becd..5f230757 100644 --- a/web/default/src/components/ui/alert-dialog.tsx +++ b/web/default/src/components/ui/alert-dialog.tsx @@ -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 ( @@ -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} diff --git a/web/default/src/components/ui/sheet.tsx b/web/default/src/components/ui/sheet.tsx index a8549bbf..a46dc4f9 100644 --- a/web/default/src/components/ui/sheet.tsx +++ b/web/default/src/components/ui/sheet.tsx @@ -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 ( @@ -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} diff --git a/web/default/src/components/ui/sidebar.tsx b/web/default/src/components/ui/sidebar.tsx index 89aff733..d34fa276 100644 --- a/web/default/src/components/ui/sidebar.tsx +++ b/web/default/src/components/ui/sidebar.tsx @@ -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, diff --git a/web/default/src/components/ui/tabs.tsx b/web/default/src/components/ui/tabs.tsx index 9db55532..fdc34df8 100644 --- a/web/default/src/components/ui/tabs.tsx +++ b/web/default/src/components/ui/tabs.tsx @@ -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} diff --git a/web/default/src/context/theme-customization-provider.tsx b/web/default/src/context/theme-customization-provider.tsx index 2d404f81..c6c3563d 100644 --- a/web/default/src/context/theme-customization-provider.tsx +++ b/web/default/src/context/theme-customization-provider.tsx @@ -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 via data-* attributes so theme-presets.css can - // override CSS variables at the right cascade layer. - useEffect(() => { + // Mirror state to 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]) diff --git a/web/default/src/context/theme-provider.tsx b/web/default/src/context/theme-provider.tsx index c93bee19..dd53e4ea 100644 --- a/web/default/src/context/theme-provider.tsx +++ b/web/default/src/context/theme-provider.tsx @@ -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)') diff --git a/web/default/src/features/auth/sign-in/components/user-auth-form.tsx b/web/default/src/features/auth/sign-in/components/user-auth-form.tsx index c58602b9..2f290969 100644 --- a/web/default/src/features/auth/sign-in/components/user-auth-form.tsx +++ b/web/default/src/features/auth/sign-in/components/user-auth-form.tsx @@ -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' diff --git a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx index c2572ffd..5b8b532a 100644 --- a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx +++ b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx @@ -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' diff --git a/web/default/src/features/channels/components/channel-card.tsx b/web/default/src/features/channels/components/channel-card.tsx index 7f49883a..80610555 100644 --- a/web/default/src/features/channels/components/channel-card.tsx +++ b/web/default/src/features/channels/components/channel-card.tsx @@ -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 = - - 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 ( -
- {/* Provider identity, status, selection, and every row action remain - immediately available. The wrapping layout avoids mobile clipping. */} -
-
+ +
+
{!isTagRow && selectCell && ( - {selectCell} + {selectCell} )} - {visibleColumnIds.has('type') && ( -
{typeCell}
- )} -
-
- {visibleColumnIds.has('status') && statusCell} - {actionsCell} -
-
-
- {visibleColumnIds.has('name') && ( - - {nameCell ?? emptyValue} - +
+ {visibleColumnIds.has('name') && ( +
+ {nameCell} +
+ )} + {visibleColumnIds.has('type') && ( +
{typeCell}
+ )} +
+ +
+ {visibleColumnIds.has('status') && statusCell} + {actionsCell} +
+
+ + {hasStatRows && ( +
+ {showId && ( + + {idCell} + + )} + {visibleColumnIds.has('balance') && ( + + {balanceCell} + + )} + {visibleColumnIds.has('response_time') && ( + + {responseCell} + + )} + {showTestTime && ( + + {testCell} + + )} + {visibleColumnIds.has('priority') && ( + + {priorityCell} + + )} + {visibleColumnIds.has('weight') && ( + + {weightCell} + + )} + {showTag && ( + + {tagCell} + + )} +
)} - {!isTagRow && visibleColumnIds.has('id') && ( - - {idCell ?? emptyValue} - - )} - {visibleColumnIds.has('balance') && ( - - {balanceCell ?? emptyValue} - - )} - {!isTagRow && visibleColumnIds.has('models') && ( - - {modelsCell ?? emptyValue} - - )} - {visibleColumnIds.has('response_time') && ( - - {responseCell ?? emptyValue} - - )} - {!isTagRow && visibleColumnIds.has('test_time') && ( - - {testCell ?? emptyValue} - + + {hasBadgeSections && ( +
+ {visibleColumnIds.has('group') && ( + + {groupsCell ?? ( + - + )} + + )} + {showModels && ( + + {modelsCell ?? ( + - + )} + + )} +
)}
- - {detailsCount > 0 && ( - - {visibleColumnIds.has('group') && ( - - {groupsCell ?? emptyValue} - - )} - {!isTagRow && visibleColumnIds.has('tag') && ( - - {tagCell ?? emptyValue} - - )} - {visibleColumnIds.has('priority') && ( - - {priorityCell ?? emptyValue} - - )} - {visibleColumnIds.has('weight') && ( - - {weightCell ?? emptyValue} - - )} - - )} -
+ ) } diff --git a/web/default/src/features/channels/components/channels-primary-buttons.tsx b/web/default/src/features/channels/components/channels-primary-buttons.tsx index 0d797e1d..0d0c2fc3 100644 --- a/web/default/src/features/channels/components/channels-primary-buttons.tsx +++ b/web/default/src/features/channels/components/channels-primary-buttons.tsx @@ -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 ( <>
- {/* Desktop: Toggle switches visible */} -
- - - -
+ -
- - - -
+ -
- - - +
{/* Create Channel */} diff --git a/web/default/src/features/channels/components/channels-table.tsx b/web/default/src/features/channels/components/channels-table.tsx index bee0d137..c61217d2 100644 --- a/web/default/src/features/channels/components/channels-table.tsx +++ b/web/default/src/features/channels/components/channels-table.tsx @@ -419,7 +419,7 @@ export function ChannelsTable() { renderCard={(row, { 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...'), diff --git a/web/default/src/features/channels/components/data-table-row-actions.tsx b/web/default/src/features/channels/components/data-table-row-actions.tsx index 1df9dece..911049c1 100644 --- a/web/default/src/features/channels/components/data-table-row-actions.tsx +++ b/web/default/src/features/channels/components/data-table-row-actions.tsx @@ -163,7 +163,13 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { } return ( -
+
{layout !== 'card' && ( )} - - - } - > - {isTesting ? ( - - ) : ( - - )} - - {t('Test Connection')} - - - {layout === 'card' && ( + {layout !== 'card' && ( { - e.stopPropagation() - handleTest() - }} - aria-label={t('Test Channel Connection')} + onClick={handleDirectTest} + disabled={isTesting} + aria-label={t('Test Connection')} /> } > - + {isTesting ? ( + + ) : ( + + )} - {t('Test Channel Connection')} + {t('Test Connection')} )} - - - } - > - {statusIcon} - - - {isEnabled ? t('Disable') : t('Enable')} - - + {layout !== 'card' && ( + + + } + > + {statusIcon} + + + {isEnabled ? t('Disable') : t('Enable')} + + + )} + {layout === 'card' && ( + void handleToggleStatus()} + className={ + isEnabled + ? 'text-destructive focus:text-destructive' + : 'text-success focus:text-success' + } + > + {isEnabled ? t('Disable') : t('Enable')} + {statusIcon} + + )} {/* Query Balance */} diff --git a/web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx b/web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx index e99674c4..44aceaaa 100644 --- a/web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx @@ -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={ diff --git a/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx b/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx index 504cc8e8..710f014f 100644 --- a/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx @@ -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={ diff --git a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx index fff852e8..70ac0096 100644 --- a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx @@ -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' diff --git a/web/default/src/features/channels/components/dialogs/status-code-risk-dialog.tsx b/web/default/src/features/channels/components/dialogs/status-code-risk-dialog.tsx index 0061f45a..07f0c67b 100644 --- a/web/default/src/features/channels/components/dialogs/status-code-risk-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/status-code-risk-dialog.tsx @@ -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' diff --git a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx index 43cbfb51..ef242354 100644 --- a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx @@ -195,7 +195,7 @@ export function TagBatchEditDialog({ {currentTag} } - contentClassName='max-w-2xl' + contentClassName='sm:max-w-2xl' contentHeight='auto' bodyClassName='space-y-4' footer={ diff --git a/web/default/src/features/keys/components/api-key-card.tsx b/web/default/src/features/keys/components/api-key-card.tsx index 7b0845dc..5d341c36 100644 --- a/web/default/src/features/keys/components/api-key-card.tsx +++ b/web/default/src/features/keys/components/api-key-card.tsx @@ -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 }) { 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 ( - <> -
- {visibleColumnIds.has('name') && ( - - {renderApiKeyCell(props.row, 'name')} - - )} - {visibleColumnIds.has('key') && ( - - {renderApiKeyCell(props.row, 'key')} - - )} - {visibleColumnIds.has('quota') && ( - - {apiKey.unlimited_quota ? ( - {t('Unlimited')} - ) : ( - - {formatQuota(apiKey.remain_quota)} - - {' / '} - {formatQuota(totalQuota)} - - - )} - +
+
+
+ {visibleColumnIds.has('name') && ( +
+ {renderApiKeyCell(props.row, 'name')} +
+ )} + {visibleColumnIds.has('key') && ( +
+ {renderApiKeyCell(props.row, 'key')} +
+ )} +
+ {visibleColumnIds.has('status') && ( +
+ {renderApiKeyCell(props.row, 'status')} +
)}
- {detailsCount > 0 && ( - - {visibleColumnIds.has('status') && ( - - {renderApiKeyCell(props.row, 'status')} - + {hasMetaRows && ( +
+ {visibleColumnIds.has('quota') && ( + + {apiKey.unlimited_quota ? ( + {t('Unlimited')} + ) : ( + + {formatQuota(apiKey.remain_quota)} + + {' / '} + {formatQuota(totalQuota)} + + + )} + )} {visibleColumnIds.has('group') && ( - + {renderApiKeyCell(props.row, 'group')} - + )} + {visibleColumnIds.has('created_time') && ( + + {renderApiKeyCell(props.row, 'created_time')} + + )} + {visibleColumnIds.has('accessed_time') && ( + + {renderApiKeyCell(props.row, 'accessed_time')} + + )} + {visibleColumnIds.has('expired_time') && ( + + {renderApiKeyCell(props.row, 'expired_time')} + + )} +
+ )} + + {hasDetailSections && ( +
{visibleColumnIds.has('model_limits') && ( - + )} {visibleColumnIds.has('allow_ips') && ( - + )} - {visibleColumnIds.has('created_time') && ( - - {renderApiKeyCell(props.row, 'created_time')} - - )} - {visibleColumnIds.has('accessed_time') && ( - - {renderApiKeyCell(props.row, 'accessed_time')} - - )} - {visibleColumnIds.has('expired_time') && ( - - {renderApiKeyCell(props.row, 'expired_time')} - - )} - {visibleColumnIds.has('actions') && ( - - {renderApiKeyCell(props.row, 'actions')} - - )} - +
)} - + + {visibleColumnIds.has('actions') && ( +
+ {renderApiKeyCell(props.row, 'actions')} +
+ )} +
) } diff --git a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx index da113f44..51db42fb 100644 --- a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx +++ b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx @@ -260,9 +260,7 @@ export function ApiKeysMutateDrawer({ } }} > - + {isUpdate ? t('Update API Key') : t('Create API Key')} diff --git a/web/default/src/features/models/components/dialogs/description-dialog.tsx b/web/default/src/features/models/components/dialogs/description-dialog.tsx index e30f9ad8..565a80c4 100644 --- a/web/default/src/features/models/components/dialogs/description-dialog.tsx +++ b/web/default/src/features/models/components/dialogs/description-dialog.tsx @@ -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' > diff --git a/web/default/src/features/models/components/dialogs/missing-models-dialog.tsx b/web/default/src/features/models/components/dialogs/missing-models-dialog.tsx index 2c76364c..bd6cb6a4 100644 --- a/web/default/src/features/models/components/dialogs/missing-models-dialog.tsx +++ b/web/default/src/features/models/components/dialogs/missing-models-dialog.tsx @@ -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' diff --git a/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx b/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx index f5aaa4fd..5c86154a 100644 --- a/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx +++ b/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx @@ -212,7 +212,7 @@ export function DynamicPricingBreakdown({
)} -
+
{t('Raw expression')}
@@ -275,10 +275,7 @@ export function DynamicPricingBreakdown({ )} >
- + {tier.label || t('Default')} {isMatched && ( @@ -302,12 +299,12 @@ export function DynamicPricingBreakdown({ ) return (
-
+
{t(v.shortLabel)}
@@ -357,10 +354,7 @@ export function DynamicPricingBreakdown({ return ( <>
- + {tier.label || t('Default')} {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) => { diff --git a/web/default/src/features/pricing/components/index.ts b/web/default/src/features/pricing/components/index.ts index 2acb49cb..3d3338b2 100644 --- a/web/default/src/features/pricing/components/index.ts +++ b/web/default/src/features/pricing/components/index.ts @@ -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' diff --git a/web/default/src/features/pricing/components/loading-skeleton.tsx b/web/default/src/features/pricing/components/loading-skeleton.tsx index bf20d4d7..b51b78fa 100644 --- a/web/default/src/features/pricing/components/loading-skeleton.tsx +++ b/web/default/src/features/pricing/components/loading-skeleton.tsx @@ -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 ( -
-
- - +
+
+ + +
+
+ + {viewMode === VIEW_MODES.TABLE ? ( + + ) : ( + + )}
- - - {viewMode === VIEW_MODES.TABLE ? ( - - ) : ( - - )}
) } @@ -47,11 +74,11 @@ export function LoadingSkeleton(props: LoadingSkeletonProps) { function CardContentSkeleton() { return (
- {Array.from({ length: 9 }).map((_, i) => ( -
+ {CARD_SKELETONS.map((key) => ( +
- +
@@ -80,64 +107,41 @@ function CardContentSkeleton() { function FilterBarSkeleton() { return ( -
-
-
- {[80, 90, 75, 85, 70].map((width, i) => ( - - ))} -
-
- - - - +
+
+ +
+ + + +
- +
) } function TableContentSkeleton() { - const columns = [ - { width: 200 }, - { width: 100 }, - { width: 100 }, - { width: 100 }, - { width: 80 }, - { width: 100 }, - ] - return (
-
- {columns.map((col, i) => ( - +
+ + {PRICE_COLUMNS.map((column) => ( + ))}
- {Array.from({ length: 10 }).map((_, i) => ( + {TABLE_ROWS.map((row) => (
- {columns.map((col, j) => ( - + + {PRICE_COLUMNS.map((column) => ( + ))}
))} @@ -145,8 +149,8 @@ function TableContentSkeleton() {
- {Array.from({ length: 4 }).map((_, i) => ( - + {PAGINATION_ITEMS.map((item) => ( + ))}
diff --git a/web/default/src/features/pricing/components/model-card-grid.tsx b/web/default/src/features/pricing/components/model-card-grid.tsx index d5707382..3cc08377 100644 --- a/web/default/src/features/pricing/components/model-card-grid.tsx +++ b/web/default/src/features/pricing/components/model-card-grid.tsx @@ -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 (
-
+
{pagedModels.map((model) => ( {totalPages > 1 && ( -
+

{t('Page {{current}} of {{total}}', { current: currentPage, @@ -105,7 +112,7 @@ export function ModelCardGrid(props: ModelCardGridProps) { disabled={currentPage <= 1} className='gap-1.5' > - +

diff --git a/web/default/src/features/pricing/components/model-card.tsx b/web/default/src/features/pricing/components/model-card.tsx index 14f39a4e..f70ad5ef 100644 --- a/web/default/src/features/pricing/components/model-card.tsx +++ b/web/default/src/features/pricing/components/model-card.tsx @@ -16,14 +16,14 @@ along with this program. If not, see . 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 ( +
+

{props.label}

+

+ {props.value} + + / {props.unit} + +

+
+ ) +} + 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 = ( - - - {t('Special billing expression')} - - - {dynamicSummary.rawExpression} - - - ) - } else if (dynamicSummary.primaryEntries.length > 0) { - priceSummary = ( - <> - {dynamicSummary.primaryEntries.map((entry) => ( - - {t(entry.shortLabel)}{' '} - - {entry.formatted} - - /{tokenUnitLabel} - - ))} - - ) - } else { - priceSummary = ( - - {t('Dynamic Pricing')} - - ) - } + let priceContent: ReactNode + if (dynamicSummary?.isSpecialExpression) { + priceContent = ( +
+

+ {t('Special billing expression')} +

+ + {dynamicSummary.rawExpression} + +
+ ) + } else if (dynamicSummary && dynamicSummary.primaryEntries.length > 0) { + priceContent = ( +
+ {dynamicSummary.primaryEntries.slice(0, 2).map((entry) => ( + + ))} +
+ ) } else if (isTokenBased) { - priceSummary = ( - <> - - {t('Input')}{' '} - - {formatPrice( - props.model, - 'input', - tokenUnit, - showRechargePrice, - priceRate, - usdExchangeRate, - props.selectedGroup - )} - - /{tokenUnitLabel} - - - {t('Output')}{' '} - - {formatPrice( - props.model, - 'output', - tokenUnit, - showRechargePrice, - priceRate, - usdExchangeRate, - props.selectedGroup - )} - - /{tokenUnitLabel} - - {hasCachedPrice && ( - - {t('Cached')}{' '} - - {formatPrice( - props.model, - 'cache', - tokenUnit, - showRechargePrice, - priceRate, - usdExchangeRate, - props.selectedGroup - )} - - + priceContent = ( +
+ + + {cachedPrice && ( + )} - +
) } else { - priceSummary = ( - - - {formatRequestPrice( - props.model, - showRechargePrice, - priceRate, - usdExchangeRate, - props.selectedGroup - )} - {' '} - / {t('request')} - + priceContent = ( + ) } return ( -
- {/* Header: icon + name + price + actions */} -
-
-
- {modelIcon || ( - - {initial} - - )} -
-
-

- {props.model.model_name} -

-
- {priceSummary} -
-
+
+
+
+ {modelIcon || ( + + {props.model.model_name?.charAt(0).toUpperCase() || '?'} + + )}
-
- - +
+
+

+ {props.model.model_name} +

+ +
+

+ {props.model.vendor_name || + (isTokenBased ? t('Token-based') : t('Per Request'))} +

+ +
- {/* Description */} -

+

{props.model.description || t('No description available.')}

- {/* Footer: left metadata and right performance summary share row alignment */} -
-
- {primaryGroup && ( - - {primaryGroup} {t('Groups')} - - )} - - {isTokenBased ? t('Token-based') : t('Per Request')} - - {isDynamicPricing && ( - - {t('Dynamic Pricing')} - - )} -
- +
{priceContent}
-
- {bottomTags.map((item) => ( - - {item} - +
+
+ {groups.slice(0, 1).map((group) => ( + + {group} + ))} - - {tokenUnitLabel} - - {hiddenCount > 0 && ( - - +{hiddenCount} + {visibleTags.map((tag) => ( + + {tag} + + ))} + {hiddenTagCount > 0 && ( + + +{hiddenTagCount} )}
+ +
-
+
) }) diff --git a/web/default/src/features/pricing/components/model-details-api.tsx b/web/default/src/features/pricing/components/model-details-api.tsx index 6627138f..62c6674a 100644 --- a/web/default/src/features/pricing/components/model-details-api.tsx +++ b/web/default/src/features/pricing/components/model-details-api.tsx @@ -507,7 +507,7 @@ function CodeSamplesSection(props: { {ep.type} @@ -523,7 +523,7 @@ function CodeSamplesSection(props: { > {(Object.keys(LANG_LABELS) as Lang[]).map((l) => ( - + {LANG_LABELS[l]} ))} @@ -598,7 +598,7 @@ function SupportedParametersSection(props: { model: PricingModel }) { cell: (p) => ( {p.type} diff --git a/web/default/src/features/pricing/components/model-details-performance.tsx b/web/default/src/features/pricing/components/model-details-performance.tsx index 07d52dbc..5baa40db 100644 --- a/web/default/src/features/pricing/components/model-details-performance.tsx +++ b/web/default/src/features/pricing/components/model-details-performance.tsx @@ -51,7 +51,7 @@ function StatCard(props: { const Icon = props.icon return (
- + {props.label} diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index 46b07389..5fa39296 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -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 ( -

- {props.children} -

+
+

{props.children}

+ {props.description && ( +

+ {props.description} +

+ )} +
) } @@ -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 ( -
- -
-
- {props.label} -
-
- {props.value} -
+
+
+ {props.label} +
+
+ {props.value}
) @@ -212,19 +207,13 @@ function OverviewSummaryGrid(props: { model: PricingModel }) { : 0 return ( -
+
+ - {props.items.map((item) => ( - + {item} - + ))}
) @@ -250,7 +236,7 @@ function CatalogPillList(props: { items: string[] }) { function CatalogTextValue(props: { children: React.ReactNode }) { return ( - + {props.children} ) @@ -258,8 +244,8 @@ function CatalogTextValue(props: { children: React.ReactNode }) { function CatalogInfoCell(props: { label: string; children: React.ReactNode }) { return ( -
- +
+ {props.label} {props.children} @@ -358,23 +344,20 @@ function ModelBackendQuickStats(props: { model: PricingModel }) { if (stats.length === 0) return null return ( -
+
{stats.map((stat) => { const Icon = stat.icon return ( -
- - +
+ + {stat.label} {stat.value} {stat.hint && ( - + {stat.hint} )} @@ -401,11 +384,9 @@ function ModelBackendSignalsSection(props: { model: PricingModel }) { return (
- - {t('Capabilities')} / {t('Supported modalities')} - -
- {capabilities.length > 0 ? ( + {t('Capabilities')} +
+ {capabilities.length > 0 && ( t( @@ -414,13 +395,11 @@ function ModelBackendSignalsSection(props: { model: PricingModel }) { ) )} /> - ) : ( -
)} {(inputModalities.length > 0 || outputModalities.length > 0) && (
{inputModalities.length > 0 && ( -
+
{t('Input')} @@ -430,7 +409,7 @@ function ModelBackendSignalsSection(props: { model: PricingModel }) {
)} {outputModalities.length > 0 && ( -
+
{t('Output')} @@ -475,7 +454,11 @@ function ModelBackendProviderSection(props: { model: PricingModel }) { if (groups.length > 0) { cells.push( - +
+ {groups.map((group) => ( + + ))} +
) } @@ -509,7 +492,7 @@ function ModelBackendProviderSection(props: { model: PricingModel }) { return (
{t('Model')} -
+
{cells}
@@ -519,7 +502,6 @@ function ModelBackendProviderSection(props: { model: PricingModel }) { function ModelBackendDetailsSection(props: { model: PricingModel }) { return ( <> - @@ -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 ( -
-
- {modelIcon} -

- {model.model_name} -

- -
-
- {model.vendor_name && ( - {model.vendor_name} - )} - · - - {model.quota_type === QUOTA_TYPE_VALUES.TOKEN - ? t('Token-based') - : t('Per Request')} - - {model.billing_mode === 'tiered_expr' && model.billing_expr && ( - <> - · - - {isSpecialExpression - ? t('Special billing expression') - : t('Dynamic Pricing')} +
+
+
+ {modelIcon || ( + + {model.model_name?.charAt(0).toUpperCase() || '?'} - - )} + )} +
+
+
+

+ {model.model_name} +

+ + {model.billing_mode === 'tiered_expr' && model.billing_expr && ( + + {isSpecialExpression + ? t('Special billing expression') + : t('Dynamic Pricing')} + + )} +
+
+ {model.vendor_name && {model.vendor_name}} + {model.vendor_name && ( + · + )} + + {model.quota_type === QUOTA_TYPE_VALUES.TOKEN + ? t('Token-based') + : t('Per Request')} + +
+
+ {description && ( -

+

{description}

)} + + {(tags.length > 0 || endpoints.length > 0) && ( +
+ {tags.map((tag) => ( + + {tag} + + ))} + {endpoints.map((endpoint) => ( + + {endpoint} + + ))} +
+ )}
) } @@ -653,16 +664,16 @@ function PriceSection(props: { if (dynamicSummary.isSpecialExpression) { return (
- {t('Base Price')} -
+ {t('Pricing')} +
{t('Special billing expression')}
-

+

{t('Unable to parse structured pricing')}

-
+
{t('Raw expression')}
@@ -674,55 +685,37 @@ function PriceSection(props: { ) } + const priceRows = [ + ...dynamicSummary.primaryEntries, + ...dynamicSummary.secondaryEntries, + ] + return (
- {t('Base Price')} - {dynamicSummary.primaryEntries.length > 0 ? ( -
- {dynamicSummary.primaryEntries.map((entry) => ( + {t('Pricing')} +
+
+ {t('Text tokens')} + + {t('Prices shown per')} {tokenUnitLabel} {t('tokens')} + +
+
+ {priceRows.map((entry) => (
-
+ {t(entry.shortLabel)} -
-
+ + {entry.formatted} - - / {tokenUnitLabel} - -
+
))}
- ) : ( -

- {t('Dynamic Pricing')} -

- )} - {dynamicSummary.secondaryEntries.length > 0 && ( -
-
- {dynamicSummary.secondaryEntries.map((entry) => ( -
- - {t(entry.shortLabel)} - - - {entry.formatted} - - / {tokenUnitLabel} - - -
- ))} -
-
- )} +
) } @@ -730,77 +723,72 @@ function PriceSection(props: { if (!isTokenBased) { return (
- {t('Base Price')} -
- - {t('Per request')} - - - {formatFixedPrice( - props.model, - baseGroupKey, - props.showRechargePrice, - props.priceRate, - props.usdExchangeRate, - baseGroupRatioMap - )} - + {t('Pricing')} +
+
+ + {t('Per request')} + + + {formatFixedPrice( + props.model, + baseGroupKey, + props.showRechargePrice, + props.priceRate, + props.usdExchangeRate, + baseGroupRatioMap + )} + +
) } 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 - )} - - / {tokenUnitLabel} - - - ) + const priceRows = [ + ...primaryPriceTypes, + ...secondaryItems.map((item) => ({ + label: item.label, + type: item.type, + })), + ] return (
- {t('Base Price')} -
- {primaryPriceTypes.map((item) => ( -
-
{item.label}
-
- {renderPrice(item.type)} -
-
- ))} -
- {secondaryItems.length > 0 && ( -
-
- {secondaryItems.map((item) => ( -
- - {item.label} - - - {renderPrice(item.type)} - -
- ))} -
+ {t('Pricing')} +
+
+ {t('Text tokens')} + + {t('Prices shown per')} {tokenUnitLabel} {t('tokens')} +
- )} +
+ {priceRows.map((item) => ( +
+ + {item.label} + + + {formatGroupPrice( + props.model, + baseGroupKey, + item.type, + props.tokenUnit, + props.showRechargePrice, + props.priceRate, + props.usdExchangeRate, + baseGroupRatioMap + )} + +
+ ))} +
+
) } @@ -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: { )}

-
+
{t('Raw expression')}
@@ -1166,53 +1158,52 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) { Boolean(props.model.billing_expr) return ( -
+
+ + - - + + {TAB_VALUES.map((value) => { const Icon = TAB_META[value].icon return ( - + ) })} - - - -
- {t('Pricing')} - - {isDynamic && ( - - )} - -
- + + + {isDynamic && ( + + )} + @@ -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 ( - - - - {props.model.model_name} - {t('Model details')} - -
- -
-
-
- ) -} - export function ModelDetails() { const { t } = useTranslation() const { modelId } = useParams({ from: '/pricing/$modelId/' }) @@ -1295,8 +1253,8 @@ export function ModelDetails() { if (isLoading) { return ( - -
+ +
@@ -1304,13 +1262,13 @@ export function ModelDetails() {
- {Array.from({ length: 4 }).map((_, i) => ( - + {['stats-a', 'stats-b', 'stats-c', 'stats-d'].map((key) => ( + ))}
- {Array.from({ length: 4 }).map((_, i) => ( - + {['block-a', 'block-b', 'block-c', 'block-d'].map((key) => ( + ))}
@@ -1320,8 +1278,8 @@ export function ModelDetails() { if (!model) { return ( - -
+ +

{t('Model not found')}

@@ -1337,14 +1295,14 @@ export function ModelDetails() { } return ( - -
+ + @@ -1364,7 +1322,7 @@ export function ModelDetails() { >) || {} } /> -
+
) } diff --git a/web/default/src/features/pricing/components/pricing-columns.tsx b/web/default/src/features/pricing/components/pricing-columns.tsx index 04633c54..07bbe38a 100644 --- a/web/default/src/features/pricing/components/pricing-columns.tsx +++ b/web/default/src/features/pricing/components/pricing-columns.tsx @@ -17,18 +17,16 @@ along with this program. If not, see . 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 +} + +type PriceColumnType = Extract + +const DYNAMIC_FIELD_BY_PRICE_TYPE: Record = { + input: 'inputPrice', + cache: 'cacheReadPrice', + output: 'outputPrice', +} + +function renderEmptyCell(align: 'left' | 'right' = 'left'): ReactNode { + const dash = ( + + ) + if (align === 'right') { + return
{dash}
+ } + return dash +} + +function renderEmptyPrice(): ReactNode { + return renderEmptyCell('right') +} + +function renderPriceCell( + props: { + model: PricingModel + priceType: PriceColumnType + options: Required< + Omit + > & { + 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 ( +
+

+ {t('Special billing expression')} +

+

+ {t('View details')} +

+
+ ) + } + + if (dynamicSummary) { + const entry = dynamicSummary.entries.find( + (item) => item.field === DYNAMIC_FIELD_BY_PRICE_TYPE[props.priceType] + ) + if (!entry) return renderEmptyPrice() + + return ( +
+

+ {stripTrailingZeros(entry.formatted)} +

+

+ / {tokenUnitLabel} {t('tokens')} + {dynamicSummary.tierCount > 1 && + ` · ${t('{{count}} tiers', { + count: dynamicSummary.tierCount, + })}`} +

+
+ ) + } + + if (!isTokenBasedModel(props.model)) { + if (props.priceType !== 'input') return renderEmptyPrice() + return ( +
+

+ {stripTrailingZeros( + formatRequestPrice( + props.model, + props.options.showRechargePrice, + props.options.priceRate, + props.options.usdExchangeRate, + props.options.selectedGroup + ) + )} +

+

/ {t('request')}

+
+ ) + } + + if (props.priceType === 'cache' && props.model.cache_ratio == null) { + return renderEmptyPrice() + } + + return ( +
+

+ {stripTrailingZeros( + formatPrice( + props.model, + props.priceType, + props.options.tokenUnit, + props.options.showRechargePrice, + props.options.priceRate, + props.options.usdExchangeRate, + props.options.selectedGroup + ) + )} +

+

+ / {tokenUnitLabel} {t('tokens')} +

+
+ ) } export function usePricingColumns( options: PricingColumnsOptions = {} ): ColumnDef[] { 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 }) => ( - - ), + 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 ( -
- {modelIcon} - - {model.model_name} - -
- ) - }, - minSize: 200, - }, - - // Type column - { - accessorKey: 'quota_type', - header: t('Type'), - cell: ({ row }) => { - const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN - return ( - - {isTokenBased ? t('Token') : t('Request')} - - ) - }, - size: 80, - enableSorting: false, - }, - - // Price column - { - accessorKey: 'price', - meta: { label: t('Price') }, - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const model = row.original - const dynamicSummary = getDynamicPricingSummary(model, { - tokenUnit, - showRechargePrice, - priceRate, - usdExchangeRate, - groupRatioMultiplier: getDynamicDisplayGroupRatio( - model, - selectedGroup - ), - }) - - if (dynamicSummary) { - if (dynamicSummary.isSpecialExpression) { - return ( -
-
- {t('Special billing expression')} -
-
- {t('Unable to parse structured pricing')} -
- - {dynamicSummary.rawExpression} - -
- ) - } - - const primaryEntries = dynamicSummary.primaryEntries.slice(0, 2) - if (primaryEntries.length === 0) { - return ( - - {t('Dynamic Pricing')} - - ) - } - - return ( -
- - {primaryEntries.map((entry, index) => ( - - {index > 0 && ( - / - )} - {stripTrailingZeros(entry.formatted)} - - ))} - -
- / {tokenUnitLabel} tokens - {dynamicSummary.tierCount > 1 && - ` · ${t('{{count}} tiers', { - count: dynamicSummary.tierCount, - })}`} -
+
+
+ {modelIcon || ( + + {model.model_name?.charAt(0).toUpperCase() || '?'} + + )}
- ) - } - - 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 ( -
- - {inputPrice} - / - {outputPrice} - -
- / {tokenUnitLabel} tokens -
-
- ) - } - - const price = stripTrailingZeros( - formatRequestPrice( - model, - showRechargePrice, - priceRate, - usdExchangeRate, - selectedGroup - ) - ) - - return ( -
- {price} -
- / {t('request')} +
+

+ {model.model_name} +

+

+ {model.vendor_name || + model.description || + (isTokenBasedModel(model) + ? t('Token-based') + : t('Per Request'))} +

) }, - 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 ( - - {t('Special billing expression')} - - ) - } - - const cacheEntry = dynamicSummary.entries.find( - (entry) => entry.field === 'cacheReadPrice' - ) - if (!cacheEntry) { - return - } - - return ( -
- - {stripTrailingZeros(cacheEntry.formatted)} - -
- / {tokenUnitLabel} -
-
- ) - } - - const isTokenBased = isTokenBasedModel(model) - - if (!isTokenBased || model.cache_ratio == null) { - return - } - - const cachedPrice = stripTrailingZeros( - formatPrice( - model, - 'cache', - tokenUnit, - showRechargePrice, - priceRate, - usdExchangeRate, - selectedGroup - ) - ) - - return ( -
- {cachedPrice} -
- / {tokenUnitLabel} -
-
- ) - }, - size: 110, - enableSorting: false, - }, - - // Vendor column - { - accessorKey: 'vendor_name', - header: t('Vendor'), - cell: ({ row }) => { - const model = row.original - if (!model.vendor_name) { - return - } - const vendorIcon = model.vendor_icon - ? getLobeIcon(model.vendor_icon, 12) - : null - return ( - - {vendorIcon} - - {model.vendor_name} - - - ) - }, + id: 'input_price', + header: () =>
{t('Input')}
, + cell: ({ row }) => + renderPriceCell( + { + model: row.original, + priceType: 'input', + options: priceOptions, + }, + t + ), size: 130, enableSorting: false, }, - - // Tags column + { + id: 'cached_price', + header: () =>
{t('Cached input')}
, + cell: ({ row }) => + renderPriceCell( + { + model: row.original, + priceType: 'cache', + options: priceOptions, + }, + t + ), + size: 130, + enableSorting: false, + }, + { + id: 'output_price', + header: () =>
{t('Output')}
, + 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 + }, + size: 160, + enableSorting: false, + }, { accessorKey: 'tags', header: t('Tags'), cell: ({ row }) => { const tags = parseTags(row.original.tags) + if (tags.length === 0) { + return renderEmptyCell() + } return ( ( - + {tag} ))} /> ) }, - 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 ( ( - - {ep} + items={endpoints.map((endpoint) => ( + + {endpoint} ))} /> ) }, - 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 ( ( - + ))} - tooltipClassName='max-w-[280px] p-2' + tooltipClassName='max-w-72 p-2' /> ) }, - size: 130, + size: 140, enableSorting: false, }, ] diff --git a/web/default/src/features/pricing/components/pricing-sidebar.tsx b/web/default/src/features/pricing/components/pricing-sidebar.tsx index 422e01c8..17b7db10 100644 --- a/web/default/src/features/pricing/components/pricing-sidebar.tsx +++ b/web/default/src/features/pricing/components/pricing-sidebar.tsx @@ -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 ( - + - + {props.title} - +
@@ -246,39 +245,25 @@ export function PricingSidebar(props: PricingSidebarProps) { ] return ( -