Commit 9d1ca545 by t0ng7u

️ 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.
parent 0918bdb4
/*
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 (
......
......@@ -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'
......
......@@ -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>
)}
{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>
)}
{secondaryCells.length > 0 && (
<DataTableCardDetails count={secondaryCells.length}>
<CardFields cells={secondaryCells} />
</DataTableCardDetails>
{props.compact && wideCells.length > 0 && (
<div className='mt-3 space-y-3 border-t pt-3'>
{wideCells.map((cell) => (
<DataTableCardField
key={cell.id}
label={getCellLabel(cell)}
contentMode={cell.column.columnDef.meta?.contentMode ?? 'full'}
>
{renderCellContent(cell)}
</DataTableCardField>
))}
</div>
)}
{actionsCell && (
<div className='mt-2 -mb-0.5 flex justify-end border-t pt-2'>
<div className='mt-3 flex justify-end border-t pt-2'>
{renderCellContent(actionsCell)}
</div>
)}
</>
</div>
)
}
......@@ -150,7 +150,7 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
<div
key={key}
className={cn(
'[background-color:var(--data-table-card-bg,var(--table-row))] px-3 py-2.5',
'[background-color:var(--data-table-card-bg,var(--table-row))] px-3.5 py-3',
getRowClassName?.(row)
)}
>
......
......@@ -55,6 +55,7 @@ export interface DataTableFilterPanelProps<TData> {
searchLoading?: boolean
onReset: () => void
onSearch?: () => void
inlineActions?: boolean
className?: string
}
......@@ -144,6 +145,35 @@ export function DataTableFilterPanel<TData>(
<DataTableViewOptions table={props.table} />
) : null
const desktopActions = (
<div className='ms-auto flex shrink-0 flex-wrap items-center justify-end gap-1.5 sm:gap-2'>
{props.actionStart}
<Button
type='button'
variant={props.onSearch ? 'outline' : 'ghost'}
onClick={props.onReset}
disabled={!props.hasActiveFilters}
className={cn(
!props.onSearch && 'text-muted-foreground hover:text-foreground px-2'
)}
>
{t('Reset')}
</Button>
{props.onSearch && (
<Button
type='button'
onClick={props.onSearch}
disabled={props.searchLoading}
>
{props.searchLoading && <Loader2 className='animate-spin' />}
{t('Search')}
</Button>
)}
{props.viewToggle}
{viewOptions}
</div>
)
if (isMobile && props.mobilePinnedFilters != null) {
return (
<Drawer open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
......@@ -264,6 +294,7 @@ export function DataTableFilterPanel<TData>(
{advancedToggle}
</div>
)}
{props.inlineActions && desktopActions}
</div>
{advancedOpen && props.advancedFilters && (
......@@ -272,36 +303,12 @@ export function DataTableFilterPanel<TData>(
</div>
)}
<div className='mt-2 flex min-w-0 flex-wrap items-center gap-2'>
{props.stats}
<div className='ms-auto flex flex-wrap items-center justify-end gap-1.5 sm:gap-2'>
{props.actionStart}
<Button
type='button'
variant={props.onSearch ? 'outline' : 'ghost'}
onClick={props.onReset}
disabled={!props.hasActiveFilters}
className={cn(
!props.onSearch &&
'text-muted-foreground hover:text-foreground px-2'
)}
>
{t('Reset')}
</Button>
{props.onSearch && (
<Button
type='button'
onClick={props.onSearch}
disabled={props.searchLoading}
>
{props.searchLoading && <Loader2 className='animate-spin' />}
{t('Search')}
</Button>
)}
{props.viewToggle}
{viewOptions}
{(!props.inlineActions || props.stats != null) && (
<div className='mt-2 flex min-w-0 flex-wrap items-center gap-2'>
{props.stats}
{!props.inlineActions && desktopActions}
</div>
</div>
)}
</div>
)
}
......@@ -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}
/>
)
......
......@@ -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}
......
......@@ -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
......
......@@ -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
)
......
/*
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>
)
}
......@@ -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
)}
>
......
......@@ -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}
......
......@@ -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}
......
......@@ -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,
......
......@@ -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}
......
......@@ -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])
......
......@@ -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'
......
......@@ -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 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>
</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>
)}
{!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>
{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>
)}
{visibleColumnIds.has('response_time') && (
<DataTableCardField label={t('Response')} contentMode='full'>
{responseCell ?? emptyValue}
</DataTableCardField>
)}
{!isTagRow && visibleColumnIds.has('test_time') && (
<DataTableCardField label={t('Last Tested')} contentMode='full'>
{testCell ?? emptyValue}
</DataTableCardField>
{hasBadgeSections && (
<div className='mt-3 space-y-3 border-t pt-3'>
{visibleColumnIds.has('group') && (
<DataTableCardField label={t('Groups')} contentMode='full'>
{groupsCell ?? (
<span className='text-muted-foreground'>-</span>
)}
</DataTableCardField>
)}
{showModels && (
<DataTableCardField label={t('Models')} contentMode='full'>
{modelsCell ?? (
<span className='text-muted-foreground'>-</span>
)}
</DataTableCardField>
)}
</div>
)}
</div>
{detailsCount > 0 && (
<DataTableCardDetails count={detailsCount}>
{visibleColumnIds.has('group') && (
<DataTableCardField
label={t('Groups')}
span={2}
contentMode='summary'
>
{groupsCell ?? emptyValue}
</DataTableCardField>
)}
{!isTagRow && visibleColumnIds.has('tag') && (
<DataTableCardField label={t('Tag')} span={2} contentMode='wrap'>
{tagCell ?? emptyValue}
</DataTableCardField>
)}
{visibleColumnIds.has('priority') && (
<DataTableCardField label={t('Priority')} contentMode='full'>
{priorityCell ?? emptyValue}
</DataTableCardField>
)}
{visibleColumnIds.has('weight') && (
<DataTableCardField label={t('Weight')} contentMode='full'>
{weightCell ?? emptyValue}
</DataTableCardField>
)}
</DataTableCardDetails>
)}
</div>
</BadgeListCellDisplayContext.Provider>
</ChannelRowActionsLayoutContext.Provider>
)
}
......
......@@ -35,6 +35,7 @@ import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/design-system/button'
import { Toggle } from '@/components/design-system/toggle'
import {
DropdownMenu,
DropdownMenuContent,
......@@ -44,8 +45,6 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Tooltip,
TooltipContent,
......@@ -107,44 +106,37 @@ export function ChannelsPrimaryButtons() {
return (
<>
<div className='flex items-center gap-2'>
{/* Desktop: Toggle switches visible */}
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<ListChecks className='text-muted-foreground h-4 w-4' />
<Label
htmlFor='channel-batch-mode'
className='cursor-pointer text-sm'
{/* Desktop: view toggles */}
<div className='hidden items-center gap-1.5 sm:flex'>
<Toggle
variant='outline'
pressed={batchMode}
onPressedChange={handleBatchModeToggle}
aria-label={t('Batch Operations')}
>
<ListChecks />
{t('Batch Operations')}
</Label>
<Switch
id='channel-batch-mode'
checked={batchMode}
onCheckedChange={handleBatchModeToggle}
/>
</div>
</Toggle>
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<Tags className='text-muted-foreground h-4 w-4' />
<Label htmlFor='tag-mode' className='cursor-pointer text-sm'>
<Toggle
variant='outline'
pressed={enableTagMode}
onPressedChange={handleTagModeToggle}
aria-label={t('Tag Mode')}
>
<Tags />
{t('Tag Mode')}
</Label>
<Switch
id='tag-mode'
checked={enableTagMode}
onCheckedChange={handleTagModeToggle}
/>
</div>
</Toggle>
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<SortAsc className='text-muted-foreground h-4 w-4' />
<Label htmlFor='id-sort' className='cursor-pointer text-sm'>
<Toggle
variant='outline'
pressed={idSort}
onPressedChange={handleIdSortToggle}
aria-label={t('Sort by ID')}
>
<SortAsc />
{t('Sort by ID')}
</Label>
<Switch
id='id-sort'
checked={idSort}
onCheckedChange={handleIdSortToggle}
/>
</Toggle>
</div>
{/* Create Channel */}
......
......@@ -419,7 +419,7 @@ export function ChannelsTable() {
renderCard={(row, { isSelected }) => (
<ChannelCard row={row} isSelected={isSelected} />
)}
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-3'
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 xl:grid-cols-3'
applyHeaderSize
toolbarProps={{
searchPlaceholder: t('Filter by name, ID, or key...'),
......
......@@ -163,7 +163,13 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
}
return (
<div className='-ml-1.5 flex items-center gap-1'>
<div
className={
layout === 'card'
? 'flex items-center'
: '-ml-1.5 flex items-center gap-1'
}
>
{layout !== 'card' && (
<Tooltip>
<TooltipTrigger
......@@ -185,71 +191,54 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</Tooltip>
)}
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleDirectTest}
disabled={isTesting}
aria-label={t('Test Connection')}
/>
}
>
{isTesting ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Gauge className='size-4' />
)}
</TooltipTrigger>
<TooltipContent>{t('Test Connection')}</TooltipContent>
</Tooltip>
{layout === 'card' && (
{layout !== 'card' && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={(e) => {
e.stopPropagation()
handleTest()
}}
aria-label={t('Test Channel Connection')}
onClick={handleDirectTest}
disabled={isTesting}
aria-label={t('Test Connection')}
/>
}
>
<PlugZap className='size-4' />
{isTesting ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Gauge className='size-4' />
)}
</TooltipTrigger>
<TooltipContent>{t('Test Channel Connection')}</TooltipContent>
<TooltipContent>{t('Test Connection')}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleToggleStatus}
disabled={isTogglingStatus}
aria-label={isEnabled ? t('Disable') : t('Enable')}
className={
isEnabled
? 'text-destructive hover:text-destructive'
: 'text-success hover:text-success'
}
/>
}
>
{statusIcon}
</TooltipTrigger>
<TooltipContent>
{isEnabled ? t('Disable') : t('Enable')}
</TooltipContent>
</Tooltip>
{layout !== 'card' && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleToggleStatus}
disabled={isTogglingStatus}
aria-label={isEnabled ? t('Disable') : t('Enable')}
className={
isEnabled
? 'text-destructive hover:text-destructive'
: 'text-success hover:text-success'
}
/>
}
>
{statusIcon}
</TooltipTrigger>
<TooltipContent>
{isEnabled ? t('Disable') : t('Enable')}
</TooltipContent>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger
......@@ -281,6 +270,20 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<PlugZap size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{layout === 'card' && (
<DropdownMenuItem
disabled={isTogglingStatus}
onClick={() => void handleToggleStatus()}
className={
isEnabled
? 'text-destructive focus:text-destructive'
: 'text-success focus:text-success'
}
>
{isEnabled ? t('Disable') : t('Enable')}
<DropdownMenuShortcut>{statusIcon}</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{/* Query Balance */}
<DropdownMenuItem onClick={handleQueryBalance}>
......
......@@ -229,7 +229,7 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
description={t(
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
)}
contentClassName='max-h-[90vh] max-w-2xl'
contentClassName='max-h-[90vh] sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
......
......@@ -388,7 +388,7 @@ export function FetchModelsDialog({
t('Fetch available models from upstream')
)
}
contentClassName='max-w-3xl'
contentClassName='sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
......
......@@ -249,7 +249,7 @@ export function MultiKeyManageDialog({
description={t(
'Manage multi-key status and configuration for this channel'
)}
contentClassName='flex max-h-[90vh] max-w-5xl flex-col'
contentClassName='flex max-h-[90vh] flex-col sm:max-w-5xl'
titleClassName='flex items-center gap-2'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
......
......@@ -88,7 +88,7 @@ export function StatusCodeRiskDialog({
</>
}
description={t('High-risk status code retry risk disclaimer')}
contentClassName='max-w-lg'
contentClassName='sm:max-w-lg'
titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
......
......@@ -195,7 +195,7 @@ export function TagBatchEditDialog({
<strong>{currentTag}</strong>
</>
}
contentClassName='max-w-2xl'
contentClassName='sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
......
......@@ -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>
)}
{visibleColumnIds.has('model_limits') && (
<DataTableCardField label={t('Models')} span={2} contentMode='full'>
<ApiKeyModels apiKey={apiKey} />
</DataTableCardField>
)}
{visibleColumnIds.has('allow_ips') && (
<DataTableCardField
label={t('IP Restriction')}
span={2}
contentMode='full'
>
<ApiKeyIpRestrictions apiKey={apiKey} />
</DataTableCardField>
</DataTableCardRow>
)}
{visibleColumnIds.has('created_time') && (
<DataTableCardField label={t('Created')} contentMode='full'>
<DataTableCardRow label={t('Created')} contentMode='full'>
{renderApiKeyCell(props.row, 'created_time')}
</DataTableCardField>
</DataTableCardRow>
)}
{visibleColumnIds.has('accessed_time') && (
<DataTableCardField label={t('Last Used')} contentMode='full'>
<DataTableCardRow label={t('Last Used')} contentMode='full'>
{renderApiKeyCell(props.row, 'accessed_time')}
</DataTableCardField>
</DataTableCardRow>
)}
{visibleColumnIds.has('expired_time') && (
<DataTableCardField
label={t('Expires')}
span={2}
contentMode='full'
>
<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')} contentMode='full'>
<ApiKeyModels apiKey={apiKey} />
</DataTableCardField>
)}
{visibleColumnIds.has('actions') && (
<DataTableCardField
label={t('Operations')}
span={2}
contentMode='full'
>
{renderApiKeyCell(props.row, 'actions')}
{visibleColumnIds.has('allow_ips') && (
<DataTableCardField label={t('IP Restriction')} contentMode='full'>
<ApiKeyIpRestrictions apiKey={apiKey} />
</DataTableCardField>
)}
</DataTableCardDetails>
</div>
)}
{visibleColumnIds.has('actions') && (
<div className='mt-3 flex justify-end border-t pt-2'>
{renderApiKeyCell(props.row, 'actions')}
</div>
)}
</>
</div>
)
}
......@@ -260,9 +260,7 @@ export function ApiKeysMutateDrawer({
}
}}
>
<SheetContent
className={sideDrawerContentClassName('max-w-none sm:!max-w-[620px]')}
>
<SheetContent className={sideDrawerContentClassName('sm:max-w-[620px]')}>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>
{isUpdate ? t('Update API Key') : t('Create API Key')}
......
......@@ -41,7 +41,7 @@ export function DescriptionDialog({
onOpenChange={onOpenChange}
title={modelName}
description={t('Model Description')}
contentClassName='max-w-2xl'
contentClassName='sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
>
......
......@@ -118,7 +118,7 @@ export function MissingModelsDialog({
description={t(
'Models that are being used but not configured in the system'
)}
contentClassName='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4'
contentClassName='flex max-h-[85vh] flex-col gap-3 p-4 sm:max-w-2xl'
headerClassName='flex-shrink-0 text-start'
contentHeight='min(74vh, 760px)'
bodyClassName='space-y-4'
......
......@@ -212,7 +212,7 @@ export function DynamicPricingBreakdown({
</div>
</div>
)}
<div className='text-muted-foreground mb-1 text-xs font-medium tracking-wider uppercase'>
<div className='text-muted-foreground mb-1 text-xs font-medium'>
{t('Raw expression')}
</div>
<code className='text-muted-foreground block text-xs break-all'>
......@@ -275,10 +275,7 @@ export function DynamicPricingBreakdown({
)}
>
<div className='mb-1.5 flex flex-wrap items-center gap-1.5'>
<Badge
variant='secondary'
className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
>
<Badge variant='outline'>
{tier.label || t('Default')}
</Badge>
{isMatched && (
......@@ -302,12 +299,12 @@ export function DynamicPricingBreakdown({
)
return (
<div key={v.field} className='min-w-0'>
<div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
<div className='text-muted-foreground truncate text-xs font-medium'>
{t(v.shortLabel)}
</div>
<div
className={cn(
'truncate font-mono',
'truncate tabular-nums',
compact ? 'text-xs' : 'text-sm font-semibold'
)}
>
......@@ -357,10 +354,7 @@ export function DynamicPricingBreakdown({
return (
<>
<div className='flex flex-wrap items-center gap-1.5'>
<Badge
variant='secondary'
className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
>
<Badge variant='outline'>
{tier.label || t('Default')}
</Badge>
{isMatched && (
......@@ -389,7 +383,7 @@ export function DynamicPricingBreakdown({
compact && 'h-8'
),
cellClassName: cn(
'text-right align-top font-mono',
'text-right align-top tabular-nums',
compact ? 'py-2' : 'py-2.5'
),
cell: (tier: ParsedTier) => {
......
......@@ -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>
......
......@@ -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>
......
......@@ -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,50 +245,36 @@ 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}
options={vendorOptions}
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)}
/>
)}
/>
......
......@@ -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>
......
......@@ -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
......@@ -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,
}
......
......@@ -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>
)
}
......@@ -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() {
})
}
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%)',
}}
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}
/>
<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>
)
}
return (
<PublicLayout showMainContainer={false}>
<PublicPageShell>
<RankingsHero period={period} onPeriodChange={handlePeriodChange} />
{rankingsBody}
</PublicPageShell>
</PublicLayout>
)
}
......
......@@ -440,7 +440,7 @@ export function AnnouncementsSection({
description={t(
'Create or update system announcements for the dashboard'
)}
contentClassName='max-w-2xl'
contentClassName='sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
......
......@@ -322,7 +322,7 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
onOpenChange={setShowDialog}
title={editingFaq ? t('Edit FAQ') : t('Add FAQ')}
description={t('Create or update frequently asked questions for users')}
contentClassName='max-w-2xl'
contentClassName='sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
......
......@@ -245,7 +245,7 @@ export function RuleEditorDialog(props: Props) {
open={props.open}
onOpenChange={props.onOpenChange}
title={isEdit ? t('Edit Rule') : t('Add Rule')}
contentClassName='max-w-2xl'
contentClassName='sm:max-w-2xl'
contentHeight='auto'
bodyClassName='pr-2'
footer={
......
......@@ -55,7 +55,7 @@ export function ConflictConfirmDialog({
const { t } = useTranslation()
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className='max-w-4xl'>
<AlertDialogContent className='sm:max-w-4xl'>
<AlertDialogHeader>
<AlertDialogTitle>{t('Confirm Billing Conflicts')}</AlertDialogTitle>
<AlertDialogDescription>
......
......@@ -25,8 +25,8 @@ import {
import { useTranslation } from 'react-i18next'
import {
DataTableCardDetails,
DataTableCardField,
DataTableCardRow,
MobileCardList,
} from '@/components/data-table'
import { cn } from '@/lib/utils'
......@@ -73,76 +73,76 @@ function orderCardCells<TData>(
})
}
function CardCellField<TData>(props: {
cell: Cell<TData, unknown>
hideLabel?: boolean
className?: string
valueClassName?: string
}) {
const meta = props.cell.column.columnDef.meta
return (
<DataTableCardField
label={props.hideLabel ? undefined : getCardLabel(props.cell)}
contentMode={meta?.contentMode}
span={meta?.cardSpan}
className={props.className}
valueClassName={props.valueClassName}
>
{flexRender(props.cell.column.columnDef.cell, props.cell.getContext())}
</DataTableCardField>
)
function isWideField<TData>(cell: Cell<TData, unknown>): boolean {
const meta = cell.column.columnDef.meta
return meta?.cardSpan === 2 || meta?.contentMode === 'summary'
}
function UsageLogCard<TData>(props: { cells: Cell<TData, unknown>[] }) {
const titleCell = props.cells.find((cell) => getCardRole(cell) === 'title')
const badgeCell = props.cells.find((cell) => getCardRole(cell) === 'badge')
const primaryCells = orderCardCells(
props.cells.filter((cell) => getCardRole(cell) === 'primary')
)
const secondaryCells = orderCardCells(
props.cells.filter((cell) => getCardRole(cell) === 'secondary')
const bodyCells = orderCardCells(
props.cells.filter(
(cell) =>
getCardRole(cell) !== 'title' &&
getCardRole(cell) !== 'badge' &&
getCardRole(cell) !== 'hidden'
)
)
const rowCells = bodyCells.filter((cell) => !isWideField(cell))
const wideCells = bodyCells.filter((cell) => isWideField(cell))
return (
<>
<div className='flex min-w-0 flex-col'>
{(titleCell || badgeCell) && (
<div className='flex min-w-0 items-start justify-between gap-3'>
{titleCell && (
<CardCellField
cell={titleCell}
hideLabel
className='min-w-0 flex-1'
valueClassName='font-medium'
/>
<div className='min-w-0 flex-1 text-[15px] leading-tight font-semibold break-words'>
{flexRender(
titleCell.column.columnDef.cell,
titleCell.getContext()
)}
</div>
)}
{badgeCell && (
<CardCellField
cell={badgeCell}
hideLabel
className='max-w-1/2 shrink text-right'
valueClassName='flex justify-end text-right tabular-nums'
/>
<div className='max-w-1/2 shrink text-right tabular-nums'>
{flexRender(
badgeCell.column.columnDef.cell,
badgeCell.getContext()
)}
</div>
)}
</div>
)}
{primaryCells.length > 0 && (
<div className='mt-2 grid grid-cols-2 gap-x-3 gap-y-2'>
{primaryCells.map((cell) => (
<CardCellField key={cell.id} cell={cell} />
{rowCells.length > 0 && (
<div className='mt-3 space-y-0.5 border-t pt-3'>
{rowCells.map((cell) => (
<DataTableCardRow
key={cell.id}
label={getCardLabel(cell)}
contentMode={cell.column.columnDef.meta?.contentMode}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataTableCardRow>
))}
</div>
)}
{secondaryCells.length > 0 && (
<DataTableCardDetails count={secondaryCells.length}>
{secondaryCells.map((cell) => (
<CardCellField key={cell.id} cell={cell} />
{wideCells.length > 0 && (
<div className='mt-3 space-y-3 border-t pt-3'>
{wideCells.map((cell) => (
<DataTableCardField
key={cell.id}
label={getCardLabel(cell)}
contentMode={cell.column.columnDef.meta?.contentMode ?? 'full'}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataTableCardField>
))}
</DataTableCardDetails>
</div>
)}
</>
</div>
)
}
......
......@@ -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.",
......
......@@ -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.",
......
......@@ -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.": "管理者アカウントはすでに初期化されています。既存の認証情報を保持して、次のステップに進むことができます。",
......
......@@ -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.": "Учетная запись администратора уже инициализирована. Вы можете сохранить существующие учетные данные и перейти к следующему шагу.",
......
......@@ -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.",
......
......@@ -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.": "管理員用戶已初始化。您可以保留現有憑證並繼續下一步。",
......
......@@ -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.": "管理员账户已初始化。您可以保留现有凭据并继续下一步。",
......
/*
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;
}
......@@ -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;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment