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 { ...@@ -26,6 +26,8 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { BadgeListCellDisplayContext } from './badge-list-cell-context'
interface BadgeListCellProps { interface BadgeListCellProps {
items: React.ReactNode[] items: React.ReactNode[]
max?: number max?: number
...@@ -33,18 +35,31 @@ interface BadgeListCellProps { ...@@ -33,18 +35,31 @@ interface BadgeListCellProps {
} }
/** /**
* Table cell renderer for a list of badges with overflow tooltip. * Badge collection that stays compact in table cells and can expose every
* Displays up to `max` badges inline; remaining items appear in a tooltip. * item when rendered inside a detail-oriented card.
*/ */
export function BadgeListCell({ export function BadgeListCell({
items, items,
max = 2, max = 2,
tooltipClassName, tooltipClassName,
}: BadgeListCellProps) { }: BadgeListCellProps) {
const display = React.useContext(BadgeListCellDisplayContext)
if (items.length === 0) { if (items.length === 0) {
return <span className='text-muted-foreground text-xs'>-</span> 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 const showTooltip = items.length > max
return ( return (
......
...@@ -20,6 +20,10 @@ export { DataTablePagination } from './core/pagination' ...@@ -20,6 +20,10 @@ export { DataTablePagination } from './core/pagination'
export { DataTableColumnHeader } from './core/column-header' export { DataTableColumnHeader } from './core/column-header'
export { BadgeCell } from './core/badge-cell' export { BadgeCell } from './core/badge-cell'
export { BadgeListCell } from './core/badge-list-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 { TruncatedCell } from './core/truncated-cell'
export { DataTableViewOptions } from './toolbar/view-options' export { DataTableViewOptions } from './toolbar/view-options'
export { DataTableToolbar } from './toolbar/toolbar' export { DataTableToolbar } from './toolbar/toolbar'
...@@ -55,8 +59,8 @@ export { ...@@ -55,8 +59,8 @@ export {
} from './layout/card-grid' } from './layout/card-grid'
export { CardRowContent } from './layout/card-row-content' export { CardRowContent } from './layout/card-row-content'
export { export {
DataTableCardDetails,
DataTableCardField, DataTableCardField,
DataTableCardRow,
type DataTableContentMode, type DataTableContentMode,
} from './layout/card-field' } from './layout/card-field'
export { tableHasCompactMeta } from './layout/card-cell-utils' export { tableHasCompactMeta } from './layout/card-cell-utils'
......
...@@ -16,20 +16,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,20 +16,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { ChevronDown } from 'lucide-react' import type { ReactNode } from 'react'
import { useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export type DataTableContentMode = 'full' | 'wrap' | 'summary' 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 { interface DataTableCardFieldProps {
children: ReactNode children: ReactNode
className?: string className?: string
...@@ -39,6 +34,10 @@ interface DataTableCardFieldProps { ...@@ -39,6 +34,10 @@ interface DataTableCardFieldProps {
valueClassName?: string valueClassName?: string
} }
/**
* Stacked label-above-value field. Prefer {@link DataTableCardRow} for dense
* scannable cards; keep this for multi-line badge collections.
*/
export function DataTableCardField({ export function DataTableCardField({
children, children,
className, className,
...@@ -53,7 +52,7 @@ export function DataTableCardField({ ...@@ -53,7 +52,7 @@ export function DataTableCardField({
className={cn('min-w-0', span === 2 && 'col-span-2', className)} className={cn('min-w-0', span === 2 && 'col-span-2', className)}
> >
{label && ( {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} {label}
</div> </div>
)} )}
...@@ -62,7 +61,7 @@ export function DataTableCardField({ ...@@ -62,7 +61,7 @@ export function DataTableCardField({
className={cn( className={cn(
'min-w-0 text-sm leading-snug', 'min-w-0 text-sm leading-snug',
(contentMode === 'full' || contentMode === 'wrap') && (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', contentMode === 'full' && 'break-all',
valueClassName valueClassName
)} )}
...@@ -73,49 +72,48 @@ export function DataTableCardField({ ...@@ -73,49 +72,48 @@ export function DataTableCardField({
) )
} }
interface DataTableCardDetailsProps { interface DataTableCardRowProps {
children: ReactNode children: ReactNode
className?: string className?: string
count?: number contentMode?: DataTableContentMode
defaultOpen?: boolean 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, children,
className, className,
count, contentMode = 'wrap',
defaultOpen = false, label,
}: DataTableCardDetailsProps) { valueClassName,
const { t } = useTranslation() }: DataTableCardRowProps) {
const [open, setOpen] = useState(defaultOpen)
return ( return (
<Collapsible <div
open={open} data-slot='data-table-card-row'
onOpenChange={setOpen} className={cn(
className={cn('mt-2', className)} 'flex min-h-6 items-start justify-between gap-4 py-0.5',
className
)}
> >
<CollapsibleTrigger <span className='text-muted-foreground shrink-0 pt-0.5 text-xs select-none'>
render={ {label}
<Button </span>
type='button' <div
variant='ghost' data-slot='data-table-card-value'
size='xs' className={cn(
className='text-muted-foreground hover:text-foreground group/details -ml-2' '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',
{open ? t('Less') : t('More')} valueClassName
{!open && count != null && count > 0 && (
<span className='tabular-nums'>({count})</span>
)} )}
<ChevronDown className='size-3.5 transition-transform duration-150 group-data-[panel-open]/details:rotate-180' /> >
</CollapsibleTrigger> {children ?? <span className='text-muted-foreground'>-</span>}
<CollapsibleContent> </div>
<div className='mt-1.5 grid grid-cols-2 gap-x-3 gap-y-2 border-t pt-2'> </div>
{children}
</div>
</CollapsibleContent>
</Collapsible>
) )
} }
...@@ -169,7 +169,7 @@ export function DataTableCardGrid<TData>(props: DataTableCardGridProps<TData>) { ...@@ -169,7 +169,7 @@ export function DataTableCardGrid<TData>(props: DataTableCardGridProps<TData>) {
data-slot='data-table-card' data-slot='data-table-card'
data-state={isSelected ? 'selected' : undefined} data-state={isSelected ? 'selected' : undefined}
className={cn( 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) props.getRowClassName?.(row)
)} )}
> >
......
...@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import type { Cell, Row } from '@tanstack/react-table' import type { Cell, Row } from '@tanstack/react-table'
import { getCellLabel, renderCellContent } from './card-cell-utils' 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' type CardRole = 'title' | 'badge' | 'primary' | 'secondary' | 'hidden'
...@@ -43,26 +43,14 @@ function orderCardCells<TData>( ...@@ -43,26 +43,14 @@ function orderCardCells<TData>(
}) })
} }
function CardFields<TData>({ cells }: { cells: Cell<TData, unknown>[] }) { function isWideField<TData>(cell: Cell<TData, unknown>): boolean {
return cells.map((cell) => { const meta = cell.column.columnDef.meta
const meta = cell.column.columnDef.meta return meta?.cardSpan === 2 || meta?.contentMode === 'summary'
return (
<DataTableCardField
key={cell.id}
label={getCellLabel(cell)}
contentMode={meta?.contentMode}
span={meta?.cardSpan}
>
{renderCellContent(cell)}
</DataTableCardField>
)
})
} }
/** /**
* Shared row content for both the mobile list and optional desktop card grid. * Shared row content for both the mobile list and optional desktop card grid.
* Primary values never clip silently; lower-priority values remain available * All visible fields render immediately — no "More" click to reveal content.
* through the shared progressive details disclosure.
*/ */
export function CardRowContent<TData>(props: { export function CardRowContent<TData>(props: {
row: Row<TData> row: Row<TData>
...@@ -74,67 +62,84 @@ export function CardRowContent<TData>(props: { ...@@ -74,67 +62,84 @@ export function CardRowContent<TData>(props: {
const titleCell = cells.find((cell) => getCardRole(cell) === 'title') const titleCell = cells.find((cell) => getCardRole(cell) === 'title')
const badgeCell = cells.find((cell) => getCardRole(cell) === 'badge') const badgeCell = cells.find((cell) => getCardRole(cell) === 'badge')
const actionsCell = cells.find((cell) => cell.column.id === 'actions') const actionsCell = cells.find((cell) => cell.column.id === 'actions')
const fieldCells = orderCardCells( const bodyCells = orderCardCells(
cells.filter( cells.filter(
(cell) => (cell) =>
cell !== titleCell && cell !== titleCell &&
cell !== badgeCell && cell !== badgeCell &&
cell !== actionsCell && cell !== actionsCell &&
getCardRole(cell) === 'primary' getCardRole(cell) !== 'hidden'
)
)
const secondaryCells = orderCardCells(
cells.filter(
(cell) =>
cell !== titleCell &&
cell !== badgeCell &&
cell !== actionsCell &&
getCardRole(cell) === 'secondary'
) )
) )
const rowCells = bodyCells.filter((cell) => !isWideField(cell))
const wideCells = bodyCells.filter((cell) => isWideField(cell))
return ( return (
<> <div className='flex min-w-0 flex-col'>
{props.compact && (titleCell || badgeCell) && ( {props.compact && (titleCell || badgeCell) && (
<div className='flex min-w-0 items-start justify-between gap-3'> <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} {titleCell ? renderCellContent(titleCell) : null}
</div> </div>
{badgeCell && ( {badgeCell && (
<DataTableCardField <div className='max-w-1/2 shrink text-right'>
contentMode={badgeCell.column.columnDef.meta?.contentMode}
className='max-w-1/2 shrink'
valueClassName='flex justify-end text-right'
>
{renderCellContent(badgeCell)} {renderCellContent(badgeCell)}
</DataTableCardField> </div>
)} )}
</div> </div>
)} )}
{fieldCells.length > 0 && ( {!props.compact && (
<div <div className='grid grid-cols-2 gap-x-3 gap-y-2'>
className={ {bodyCells.map((cell) => {
props.compact const meta = cell.column.columnDef.meta
? 'mt-2 grid grid-cols-2 gap-x-3 gap-y-2' return (
: 'grid grid-cols-2 gap-x-3 gap-y-2' <DataTableCardField
} key={cell.id}
> label={getCellLabel(cell)}
<CardFields cells={fieldCells} /> 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> </div>
)} )}
{secondaryCells.length > 0 && ( {props.compact && wideCells.length > 0 && (
<DataTableCardDetails count={secondaryCells.length}> <div className='mt-3 space-y-3 border-t pt-3'>
<CardFields cells={secondaryCells} /> {wideCells.map((cell) => (
</DataTableCardDetails> <DataTableCardField
key={cell.id}
label={getCellLabel(cell)}
contentMode={cell.column.columnDef.meta?.contentMode ?? 'full'}
>
{renderCellContent(cell)}
</DataTableCardField>
))}
</div>
)} )}
{actionsCell && ( {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)} {renderCellContent(actionsCell)}
</div> </div>
)} )}
</> </div>
) )
} }
...@@ -150,7 +150,7 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) { ...@@ -150,7 +150,7 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
<div <div
key={key} key={key}
className={cn( 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) getRowClassName?.(row)
)} )}
> >
......
...@@ -55,6 +55,7 @@ export interface DataTableFilterPanelProps<TData> { ...@@ -55,6 +55,7 @@ export interface DataTableFilterPanelProps<TData> {
searchLoading?: boolean searchLoading?: boolean
onReset: () => void onReset: () => void
onSearch?: () => void onSearch?: () => void
inlineActions?: boolean
className?: string className?: string
} }
...@@ -144,6 +145,35 @@ export function DataTableFilterPanel<TData>( ...@@ -144,6 +145,35 @@ export function DataTableFilterPanel<TData>(
<DataTableViewOptions table={props.table} /> <DataTableViewOptions table={props.table} />
) : null ) : 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) { if (isMobile && props.mobilePinnedFilters != null) {
return ( return (
<Drawer open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}> <Drawer open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
...@@ -264,6 +294,7 @@ export function DataTableFilterPanel<TData>( ...@@ -264,6 +294,7 @@ export function DataTableFilterPanel<TData>(
{advancedToggle} {advancedToggle}
</div> </div>
)} )}
{props.inlineActions && desktopActions}
</div> </div>
{advancedOpen && props.advancedFilters && ( {advancedOpen && props.advancedFilters && (
...@@ -272,36 +303,12 @@ export function DataTableFilterPanel<TData>( ...@@ -272,36 +303,12 @@ export function DataTableFilterPanel<TData>(
</div> </div>
)} )}
<div className='mt-2 flex min-w-0 flex-wrap items-center gap-2'> {(!props.inlineActions || props.stats != null) && (
{props.stats} <div className='mt-2 flex min-w-0 flex-wrap items-center gap-2'>
<div className='ms-auto flex flex-wrap items-center justify-end gap-1.5 sm:gap-2'> {props.stats}
{props.actionStart} {!props.inlineActions && desktopActions}
<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> </div>
</div> )}
</div> </div>
) )
} }
...@@ -276,8 +276,11 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) { ...@@ -276,8 +276,11 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
const primarySearch = const primarySearch =
props.customSearch !== undefined ? props.customSearch : searchInput props.customSearch !== undefined ? props.customSearch : searchInput
const useWidePrimarySearch = const additionalFilterCount =
filters.length + (props.additionalSearch != null ? 1 : 0) <= 3 filters.length + (props.additionalSearch != null ? 1 : 0)
const inlineActions =
additionalFilterCount <= 3 && !hasExpandable && props.leftActions == null
const useWidePrimarySearch = !inlineActions && additionalFilterCount <= 3
const secondaryMobileFilters = const secondaryMobileFilters =
props.additionalSearch != null || props.additionalSearch != null ||
filterChips.some(Boolean) || filterChips.some(Boolean) ||
...@@ -320,6 +323,7 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) { ...@@ -320,6 +323,7 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
searchLoading={props.searchLoading} searchLoading={props.searchLoading}
onReset={handleReset} onReset={handleReset}
onSearch={hasSearch ? props.onSearch : undefined} onSearch={hasSearch ? props.onSearch : undefined}
inlineActions={inlineActions}
className={props.className} className={props.className}
/> />
) )
......
...@@ -28,13 +28,17 @@ import { cn } from '@/lib/utils' ...@@ -28,13 +28,17 @@ import { cn } from '@/lib/utils'
function TabsList({ function TabsList({
className, className,
variant = 'default',
...props ...props
}: React.ComponentProps<typeof ShadcnTabsList>) { }: React.ComponentProps<typeof ShadcnTabsList>) {
return ( return (
<ShadcnTabsList <ShadcnTabsList
data-control-size='default' data-control-size='default'
variant={variant}
className={cn( 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 className
)} )}
{...props} {...props}
......
...@@ -71,6 +71,8 @@ export function Dialog({ ...@@ -71,6 +71,8 @@ export function Dialog({
{trigger ? <DialogTrigger render={trigger} /> : null} {trigger ? <DialogTrigger render={trigger} /> : null}
<DialogContent <DialogContent
className={cn( 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', 'flex max-h-[calc(100vh-2rem)] w-full flex-col gap-4 overflow-hidden p-4 sm:max-w-2xl sm:p-6',
contentClassName, contentClassName,
dialogContentMotionClassName dialogContentMotionClassName
......
...@@ -22,13 +22,16 @@ import { cn } from '@/lib/utils' ...@@ -22,13 +22,16 @@ import { cn } from '@/lib/utils'
export const sideDrawerContentClassName = (className?: string) => export const sideDrawerContentClassName = (className?: string) =>
cn( 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', 'bg-background text-foreground flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 shadow-none',
className className
) )
export const sideDrawerHeaderClassName = (className?: string) => export const sideDrawerHeaderClassName = (className?: string) =>
cn( 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 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' ...@@ -26,6 +26,11 @@ export { AppSidebar } from './components/app-sidebar'
export { AuthenticatedLayout } from './components/authenticated-layout' export { AuthenticatedLayout } from './components/authenticated-layout'
export { PublicLayout } from './components/public-layout' export { PublicLayout } from './components/public-layout'
export { PublicHeader } from './components/public-header' 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 { PublicNavigation } from './components/public-navigation'
export { HeaderLogo } from './components/header-logo' export { HeaderLogo } from './components/header-logo'
export { NavLinkItem, NavLinkList } from './components/nav-link-item' export { NavLinkItem, NavLinkList } from './components/nav-link-item'
......
...@@ -183,7 +183,7 @@ export function RiskAcknowledgementDialog({ ...@@ -183,7 +183,7 @@ export function RiskAcknowledgementDialog({
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent <AlertDialogContent
className={cn( 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 className
)} )}
> >
......
...@@ -63,6 +63,11 @@ function AlertDialogContent({ ...@@ -63,6 +63,11 @@ function AlertDialogContent({
}: AlertDialogPrimitive.Popup.Props & { }: AlertDialogPrimitive.Popup.Props & {
size?: 'default' | 'sm' 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 ( return (
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay /> <AlertDialogOverlay />
...@@ -70,7 +75,8 @@ function AlertDialogContent({ ...@@ -70,7 +75,8 @@ function AlertDialogContent({
data-slot='alert-dialog-content' data-slot='alert-dialog-content'
data-size={size} data-size={size}
className={cn( 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 className
)} )}
{...props} {...props}
......
...@@ -65,6 +65,13 @@ function SheetContent({ ...@@ -65,6 +65,13 @@ function SheetContent({
side?: 'top' | 'right' | 'bottom' | 'left' side?: 'top' | 'right' | 'bottom' | 'left'
showCloseButton?: boolean 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 ( return (
<SheetPortal> <SheetPortal>
<SheetOverlay /> <SheetOverlay />
...@@ -72,7 +79,8 @@ function SheetContent({ ...@@ -72,7 +79,8 @@ function SheetContent({
data-slot='sheet-content' data-slot='sheet-content'
data-side={side} data-side={side}
className={cn( 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 className
)} )}
{...props} {...props}
......
...@@ -206,7 +206,7 @@ function Sidebar({ ...@@ -206,7 +206,7 @@ function Sidebar({
data-sidebar='sidebar' data-sidebar='sidebar'
data-slot='sidebar' data-slot='sidebar'
data-mobile='true' 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={ style={
{ {
'--sidebar-width': SIDEBAR_WIDTH_MOBILE, '--sidebar-width': SIDEBAR_WIDTH_MOBILE,
......
...@@ -77,7 +77,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { ...@@ -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", "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', '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', '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 className
)} )}
{...props} {...props}
......
...@@ -20,7 +20,7 @@ import { ...@@ -20,7 +20,7 @@ import {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useEffect, useLayoutEffect,
useMemo, useMemo,
useState, useState,
} from 'react' } from 'react'
...@@ -133,9 +133,11 @@ export function ThemeCustomizationProvider(props: { ...@@ -133,9 +133,11 @@ export function ThemeCustomizationProvider(props: {
) )
) )
// Mirror state to the <body> via data-* attributes so theme-presets.css can // Mirror state to <body> via data-* attributes before paint so theme-
// override CSS variables at the right cascade layer. // presets.css can override CSS variables without a one-frame size/font flash.
useEffect(() => { // useLayoutEffect (not useEffect) is required: useEffect runs after paint,
// which is exactly when users see text jump from default → cookie scale/font.
useLayoutEffect(() => {
applyAttribute( applyAttribute(
'data-theme-preset', 'data-theme-preset',
preset === DEFAULT_THEME_CUSTOMIZATION.preset ? null : preset preset === DEFAULT_THEME_CUSTOMIZATION.preset ? null : preset
...@@ -148,25 +150,25 @@ export function ThemeCustomizationProvider(props: { ...@@ -148,25 +150,25 @@ export function ThemeCustomizationProvider(props: {
// Resolving here (instead of in CSS via `:not()` selectors) keeps the // Resolving here (instead of in CSS via `:not()` selectors) keeps the
// stylesheet to one simple `[data-theme-font='serif']` selector and lets // stylesheet to one simple `[data-theme-font='serif']` selector and lets
// future presets opt into typography via `PRESET_DEFAULT_FONT` alone. // future presets opt into typography via `PRESET_DEFAULT_FONT` alone.
useEffect(() => { useLayoutEffect(() => {
applyAttribute('data-theme-font', resolveThemeFont(font, preset)) applyAttribute('data-theme-font', resolveThemeFont(font, preset))
}, [font, preset]) }, [font, preset])
useEffect(() => { useLayoutEffect(() => {
applyAttribute( applyAttribute(
'data-theme-radius', 'data-theme-radius',
radius === DEFAULT_THEME_CUSTOMIZATION.radius ? null : radius radius === DEFAULT_THEME_CUSTOMIZATION.radius ? null : radius
) )
}, [radius]) }, [radius])
useEffect(() => { useLayoutEffect(() => {
applyAttribute( applyAttribute(
'data-theme-scale', 'data-theme-scale',
scale === DEFAULT_THEME_CUSTOMIZATION.scale ? null : scale scale === DEFAULT_THEME_CUSTOMIZATION.scale ? null : scale
) )
}, [scale]) }, [scale])
useEffect(() => { useLayoutEffect(() => {
applyAttribute('data-theme-content-layout', contentLayout) applyAttribute('data-theme-content-layout', contentLayout)
}, [contentLayout]) }, [contentLayout])
......
...@@ -20,7 +20,7 @@ import { ...@@ -20,7 +20,7 @@ import {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useEffect, useLayoutEffect,
useMemo, useMemo,
useState, useState,
} from 'react' } from 'react'
...@@ -88,7 +88,8 @@ export function ThemeProvider({ ...@@ -88,7 +88,8 @@ export function ThemeProvider({
resolveTheme(getStoredTheme(storageKey, defaultTheme)) 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 root = window.document.documentElement
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
......
...@@ -414,7 +414,7 @@ export function UserAuthForm({ ...@@ -414,7 +414,7 @@ export function UserAuthForm({
description={t( description={t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.' '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' headerClassName='text-left'
contentHeight='auto' contentHeight='auto'
bodyClassName='space-y-4' bodyClassName='space-y-4'
......
...@@ -386,7 +386,7 @@ export function SignUpForm({ ...@@ -386,7 +386,7 @@ export function SignUpForm({
description={t( description={t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.' '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' headerClassName='text-left'
contentHeight='auto' contentHeight='auto'
bodyClassName='space-y-4' bodyClassName='space-y-4'
......
...@@ -20,8 +20,9 @@ import { flexRender, type Row } from '@tanstack/react-table' ...@@ -20,8 +20,9 @@ import { flexRender, type Row } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
DataTableCardDetails, BadgeListCellDisplayContext,
DataTableCardField, DataTableCardField,
DataTableCardRow,
} from '@/components/data-table' } from '@/components/data-table'
import { isTagAggregateRow } from '../lib' import { isTagAggregateRow } from '../lib'
...@@ -31,10 +32,7 @@ import { ChannelRowActionsLayoutContext } from './channel-row-actions-context' ...@@ -31,10 +32,7 @@ import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
/** /**
* Bespoke channel card for the card view. Reuses every column's existing cell * Bespoke channel card for the card view. Reuses every column's existing cell
* renderer via `flexRender`, so the table's information and interactions are * renderer via `flexRender`, so the table's information and interactions are
* preserved: row selection, provider/multi-key/IO.NET type badge, id, * preserved. All fields are always visible — no "More" disclosure.
* 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.
*/ */
function ChannelCardComponent({ function ChannelCardComponent({
row, row,
...@@ -71,111 +69,112 @@ function ChannelCardComponent({ ...@@ -71,111 +69,112 @@ function ChannelCardComponent({
const responseCell = renderCell('response_time') const responseCell = renderCell('response_time')
const testCell = renderCell('test_time') const testCell = renderCell('test_time')
const emptyValue = <span className='text-muted-foreground'>-</span> const showId = !isTagRow && visibleColumnIds.has('id')
const detailsCount = [ const showTag = !isTagRow && visibleColumnIds.has('tag')
visibleColumnIds.has('group'), const showModels = !isTagRow && visibleColumnIds.has('models')
!isTagRow && visibleColumnIds.has('tag'), const showTestTime = !isTagRow && visibleColumnIds.has('test_time')
visibleColumnIds.has('priority'), const hasStatRows =
visibleColumnIds.has('weight'), showId ||
].filter(Boolean).length showTag ||
showTestTime ||
visibleColumnIds.has('balance') ||
visibleColumnIds.has('response_time') ||
visibleColumnIds.has('priority') ||
visibleColumnIds.has('weight')
const hasBadgeSections = showModels || visibleColumnIds.has('group')
return ( return (
<ChannelRowActionsLayoutContext.Provider value='card'> <ChannelRowActionsLayoutContext.Provider value='card'>
<div <BadgeListCellDisplayContext.Provider value='full'>
data-state={isSelected ? 'selected' : undefined} <div
className='flex flex-col gap-3' data-state={isSelected ? 'selected' : undefined}
> className='flex h-full min-w-0 flex-col'
{/* Provider identity, status, selection, and every row action remain >
immediately available. The wrapping layout avoids mobile clipping. */} <div className='flex min-w-0 items-start gap-2.5'>
<div className='flex flex-wrap items-start justify-between gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2'>
{!isTagRow && selectCell && ( {!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 className='min-w-0 flex-1'>
)} {visibleColumnIds.has('name') && (
</div> <div className='min-w-0 text-[15px] leading-tight font-semibold break-words'>
<div className='flex flex-wrap items-center justify-end gap-1.5'> {nameCell}
{visibleColumnIds.has('status') && statusCell} </div>
{actionsCell} )}
{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>
<div className='grid grid-cols-2 gap-x-3 gap-y-2'> {hasStatRows && (
{visibleColumnIds.has('name') && ( <div className='mt-3 space-y-0.5 border-t pt-3'>
<DataTableCardField {showId && (
label={isTagRow ? t('Tag') : t('Name')} <DataTableCardRow label={t('ID')} contentMode='full'>
span={2} {idCell}
contentMode='wrap' </DataTableCardRow>
> )}
{nameCell ?? emptyValue} {visibleColumnIds.has('balance') && (
</DataTableCardField> <DataTableCardRow
)} label={t('Used / Remaining')}
{!isTagRow && visibleColumnIds.has('id') && ( contentMode='full'
<DataTableCardField label={t('ID')} contentMode='full'> >
{idCell ?? emptyValue} {balanceCell}
</DataTableCardField> </DataTableCardRow>
)} )}
{visibleColumnIds.has('balance') && ( {visibleColumnIds.has('response_time') && (
<DataTableCardField <DataTableCardRow label={t('Response')} contentMode='full'>
label={t('Used / Remaining')} {responseCell}
span={2} </DataTableCardRow>
contentMode='full' )}
> {showTestTime && (
{balanceCell ?? emptyValue} <DataTableCardRow label={t('Last Tested')} contentMode='full'>
</DataTableCardField> {testCell}
)} </DataTableCardRow>
{!isTagRow && visibleColumnIds.has('models') && ( )}
<DataTableCardField {visibleColumnIds.has('priority') && (
label={t('Models')} <DataTableCardRow label={t('Priority')} contentMode='full'>
span={2} {priorityCell}
contentMode='summary' </DataTableCardRow>
> )}
{modelsCell ?? emptyValue} {visibleColumnIds.has('weight') && (
</DataTableCardField> <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'> {hasBadgeSections && (
{responseCell ?? emptyValue} <div className='mt-3 space-y-3 border-t pt-3'>
</DataTableCardField> {visibleColumnIds.has('group') && (
)} <DataTableCardField label={t('Groups')} contentMode='full'>
{!isTagRow && visibleColumnIds.has('test_time') && ( {groupsCell ?? (
<DataTableCardField label={t('Last Tested')} contentMode='full'> <span className='text-muted-foreground'>-</span>
{testCell ?? emptyValue} )}
</DataTableCardField> </DataTableCardField>
)}
{showModels && (
<DataTableCardField label={t('Models')} contentMode='full'>
{modelsCell ?? (
<span className='text-muted-foreground'>-</span>
)}
</DataTableCardField>
)}
</div>
)} )}
</div> </div>
</BadgeListCellDisplayContext.Provider>
{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>
</ChannelRowActionsLayoutContext.Provider> </ChannelRowActionsLayoutContext.Provider>
) )
} }
......
...@@ -35,6 +35,7 @@ import { useTranslation } from 'react-i18next' ...@@ -35,6 +35,7 @@ import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/design-system/button' import { Button } from '@/components/design-system/button'
import { Toggle } from '@/components/design-system/toggle'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
...@@ -44,8 +45,6 @@ import { ...@@ -44,8 +45,6 @@ import {
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
...@@ -107,44 +106,37 @@ export function ChannelsPrimaryButtons() { ...@@ -107,44 +106,37 @@ export function ChannelsPrimaryButtons() {
return ( return (
<> <>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
{/* Desktop: Toggle switches visible */} {/* Desktop: view toggles */}
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'> <div className='hidden items-center gap-1.5 sm:flex'>
<ListChecks className='text-muted-foreground h-4 w-4' /> <Toggle
<Label variant='outline'
htmlFor='channel-batch-mode' pressed={batchMode}
className='cursor-pointer text-sm' onPressedChange={handleBatchModeToggle}
aria-label={t('Batch Operations')}
> >
<ListChecks />
{t('Batch Operations')} {t('Batch Operations')}
</Label> </Toggle>
<Switch
id='channel-batch-mode'
checked={batchMode}
onCheckedChange={handleBatchModeToggle}
/>
</div>
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'> <Toggle
<Tags className='text-muted-foreground h-4 w-4' /> variant='outline'
<Label htmlFor='tag-mode' className='cursor-pointer text-sm'> pressed={enableTagMode}
onPressedChange={handleTagModeToggle}
aria-label={t('Tag Mode')}
>
<Tags />
{t('Tag Mode')} {t('Tag Mode')}
</Label> </Toggle>
<Switch
id='tag-mode'
checked={enableTagMode}
onCheckedChange={handleTagModeToggle}
/>
</div>
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'> <Toggle
<SortAsc className='text-muted-foreground h-4 w-4' /> variant='outline'
<Label htmlFor='id-sort' className='cursor-pointer text-sm'> pressed={idSort}
onPressedChange={handleIdSortToggle}
aria-label={t('Sort by ID')}
>
<SortAsc />
{t('Sort by ID')} {t('Sort by ID')}
</Label> </Toggle>
<Switch
id='id-sort'
checked={idSort}
onCheckedChange={handleIdSortToggle}
/>
</div> </div>
{/* Create Channel */} {/* Create Channel */}
......
...@@ -419,7 +419,7 @@ export function ChannelsTable() { ...@@ -419,7 +419,7 @@ export function ChannelsTable() {
renderCard={(row, { isSelected }) => ( renderCard={(row, { isSelected }) => (
<ChannelCard row={row} isSelected={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 applyHeaderSize
toolbarProps={{ toolbarProps={{
searchPlaceholder: t('Filter by name, ID, or key...'), searchPlaceholder: t('Filter by name, ID, or key...'),
......
...@@ -163,7 +163,13 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -163,7 +163,13 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
} }
return ( 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' && ( {layout !== 'card' && (
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
...@@ -185,71 +191,54 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -185,71 +191,54 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</Tooltip> </Tooltip>
)} )}
<Tooltip> {layout !== 'card' && (
<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' && (
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
<Button <Button
variant='ghost' variant='ghost'
size='icon-sm' size='icon-sm'
onClick={(e) => { onClick={handleDirectTest}
e.stopPropagation() disabled={isTesting}
handleTest() aria-label={t('Test Connection')}
}}
aria-label={t('Test Channel Connection')}
/> />
} }
> >
<PlugZap className='size-4' /> {isTesting ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Gauge className='size-4' />
)}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{t('Test Channel Connection')}</TooltipContent> <TooltipContent>{t('Test Connection')}</TooltipContent>
</Tooltip> </Tooltip>
)} )}
<Tooltip> {layout !== 'card' && (
<TooltipTrigger <Tooltip>
render={ <TooltipTrigger
<Button render={
variant='ghost' <Button
size='icon-sm' variant='ghost'
onClick={handleToggleStatus} size='icon-sm'
disabled={isTogglingStatus} onClick={handleToggleStatus}
aria-label={isEnabled ? t('Disable') : t('Enable')} disabled={isTogglingStatus}
className={ aria-label={isEnabled ? t('Disable') : t('Enable')}
isEnabled className={
? 'text-destructive hover:text-destructive' isEnabled
: 'text-success hover:text-success' ? 'text-destructive hover:text-destructive'
} : 'text-success hover:text-success'
/> }
} />
> }
{statusIcon} >
</TooltipTrigger> {statusIcon}
<TooltipContent> </TooltipTrigger>
{isEnabled ? t('Disable') : t('Enable')} <TooltipContent>
</TooltipContent> {isEnabled ? t('Disable') : t('Enable')}
</Tooltip> </TooltipContent>
</Tooltip>
)}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
...@@ -281,6 +270,20 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -281,6 +270,20 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<PlugZap size={16} /> <PlugZap size={16} />
</DropdownMenuShortcut> </DropdownMenuShortcut>
</DropdownMenuItem> </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 */} {/* Query Balance */}
<DropdownMenuItem onClick={handleQueryBalance}> <DropdownMenuItem onClick={handleQueryBalance}>
......
...@@ -229,7 +229,7 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) { ...@@ -229,7 +229,7 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
description={t( description={t(
'Batch edit all channels with this tag. Leave fields empty to keep current values.' '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' contentHeight='auto'
bodyClassName='space-y-4' bodyClassName='space-y-4'
footer={ footer={
......
...@@ -388,7 +388,7 @@ export function FetchModelsDialog({ ...@@ -388,7 +388,7 @@ export function FetchModelsDialog({
t('Fetch available models from upstream') t('Fetch available models from upstream')
) )
} }
contentClassName='max-w-3xl' contentClassName='sm:max-w-3xl'
contentHeight='auto' contentHeight='auto'
bodyClassName='space-y-4' bodyClassName='space-y-4'
footer={ footer={
......
...@@ -249,7 +249,7 @@ export function MultiKeyManageDialog({ ...@@ -249,7 +249,7 @@ export function MultiKeyManageDialog({
description={t( description={t(
'Manage multi-key status and configuration for this channel' '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' titleClassName='flex items-center gap-2'
contentHeight='min(72vh, 720px)' contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4' bodyClassName='space-y-4'
......
...@@ -88,7 +88,7 @@ export function StatusCodeRiskDialog({ ...@@ -88,7 +88,7 @@ export function StatusCodeRiskDialog({
</> </>
} }
description={t('High-risk status code retry risk disclaimer')} 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' titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto' contentHeight='auto'
bodyClassName='space-y-4' bodyClassName='space-y-4'
......
...@@ -195,7 +195,7 @@ export function TagBatchEditDialog({ ...@@ -195,7 +195,7 @@ export function TagBatchEditDialog({
<strong>{currentTag}</strong> <strong>{currentTag}</strong>
</> </>
} }
contentClassName='max-w-2xl' contentClassName='sm:max-w-2xl'
contentHeight='auto' contentHeight='auto'
bodyClassName='space-y-4' bodyClassName='space-y-4'
footer={ footer={
......
...@@ -19,10 +19,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -19,10 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { flexRender, type Row } from '@tanstack/react-table' import { flexRender, type Row } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import { DataTableCardField, DataTableCardRow } from '@/components/data-table'
DataTableCardDetails,
DataTableCardField,
} from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { formatQuota } from '@/lib/format' import { formatQuota } from '@/lib/format'
...@@ -82,108 +79,99 @@ export function ApiKeyCard(props: { row: Row<ApiKey> }) { ...@@ -82,108 +79,99 @@ export function ApiKeyCard(props: { row: Row<ApiKey> }) {
const visibleColumnIds = new Set( const visibleColumnIds = new Set(
props.row.getVisibleCells().map((cell) => cell.column.id) props.row.getVisibleCells().map((cell) => cell.column.id)
) )
const detailsCount = [
'status', const hasMetaRows = [
'group', 'group',
'model_limits', 'quota',
'allow_ips',
'created_time', 'created_time',
'accessed_time', 'accessed_time',
'expired_time', 'expired_time',
'actions', ].some((columnId) => visibleColumnIds.has(columnId))
].filter((columnId) => visibleColumnIds.has(columnId)).length const hasDetailSections =
visibleColumnIds.has('model_limits') || visibleColumnIds.has('allow_ips')
return ( return (
<> <div className='flex min-w-0 flex-col'>
<div className='grid grid-cols-2 gap-x-3 gap-y-2'> <div className='flex min-w-0 items-start justify-between gap-3'>
{visibleColumnIds.has('name') && ( <div className='min-w-0 flex-1'>
<DataTableCardField {visibleColumnIds.has('name') && (
label={t('Name')} <div className='text-[15px] leading-tight font-semibold break-words'>
span={2} {renderApiKeyCell(props.row, 'name')}
contentMode='wrap' </div>
valueClassName='font-medium' )}
> {visibleColumnIds.has('key') && (
{renderApiKeyCell(props.row, 'name')} <div className='mt-1.5 min-w-0'>
</DataTableCardField> {renderApiKeyCell(props.row, 'key')}
)} </div>
{visibleColumnIds.has('key') && ( )}
<DataTableCardField label={t('API Key')} span={2} contentMode='full'> </div>
{renderApiKeyCell(props.row, 'key')} {visibleColumnIds.has('status') && (
</DataTableCardField> <div className='shrink-0'>
)} {renderApiKeyCell(props.row, 'status')}
{visibleColumnIds.has('quota') && ( </div>
<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> </div>
{detailsCount > 0 && ( {hasMetaRows && (
<DataTableCardDetails count={detailsCount}> <div className='mt-3 space-y-0.5 border-t pt-3'>
{visibleColumnIds.has('status') && ( {visibleColumnIds.has('quota') && (
<DataTableCardField label={t('Status')} contentMode='full'> <DataTableCardRow label={t('Quota')} contentMode='full'>
{renderApiKeyCell(props.row, 'status')} {apiKey.unlimited_quota ? (
</DataTableCardField> <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') && ( {visibleColumnIds.has('group') && (
<DataTableCardField label={t('Group')} contentMode='full'> <DataTableCardRow label={t('Group')} contentMode='full'>
{renderApiKeyCell(props.row, 'group')} {renderApiKeyCell(props.row, 'group')}
</DataTableCardField> </DataTableCardRow>
)}
{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>
)} )}
{visibleColumnIds.has('created_time') && ( {visibleColumnIds.has('created_time') && (
<DataTableCardField label={t('Created')} contentMode='full'> <DataTableCardRow label={t('Created')} contentMode='full'>
{renderApiKeyCell(props.row, 'created_time')} {renderApiKeyCell(props.row, 'created_time')}
</DataTableCardField> </DataTableCardRow>
)} )}
{visibleColumnIds.has('accessed_time') && ( {visibleColumnIds.has('accessed_time') && (
<DataTableCardField label={t('Last Used')} contentMode='full'> <DataTableCardRow label={t('Last Used')} contentMode='full'>
{renderApiKeyCell(props.row, 'accessed_time')} {renderApiKeyCell(props.row, 'accessed_time')}
</DataTableCardField> </DataTableCardRow>
)} )}
{visibleColumnIds.has('expired_time') && ( {visibleColumnIds.has('expired_time') && (
<DataTableCardField <DataTableCardRow label={t('Expires')} contentMode='full'>
label={t('Expires')}
span={2}
contentMode='full'
>
{renderApiKeyCell(props.row, 'expired_time')} {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> </DataTableCardField>
)} )}
{visibleColumnIds.has('actions') && ( {visibleColumnIds.has('allow_ips') && (
<DataTableCardField <DataTableCardField label={t('IP Restriction')} contentMode='full'>
label={t('Operations')} <ApiKeyIpRestrictions apiKey={apiKey} />
span={2}
contentMode='full'
>
{renderApiKeyCell(props.row, 'actions')}
</DataTableCardField> </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({ ...@@ -260,9 +260,7 @@ export function ApiKeysMutateDrawer({
} }
}} }}
> >
<SheetContent <SheetContent className={sideDrawerContentClassName('sm:max-w-[620px]')}>
className={sideDrawerContentClassName('max-w-none sm:!max-w-[620px]')}
>
<SheetHeader className={sideDrawerHeaderClassName()}> <SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle> <SheetTitle>
{isUpdate ? t('Update API Key') : t('Create API Key')} {isUpdate ? t('Update API Key') : t('Create API Key')}
......
...@@ -41,7 +41,7 @@ export function DescriptionDialog({ ...@@ -41,7 +41,7 @@ export function DescriptionDialog({
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={modelName} title={modelName}
description={t('Model Description')} description={t('Model Description')}
contentClassName='max-w-2xl' contentClassName='sm:max-w-2xl'
contentHeight='auto' contentHeight='auto'
bodyClassName='space-y-4' bodyClassName='space-y-4'
> >
......
...@@ -118,7 +118,7 @@ export function MissingModelsDialog({ ...@@ -118,7 +118,7 @@ export function MissingModelsDialog({
description={t( description={t(
'Models that are being used but not configured in the system' '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' headerClassName='flex-shrink-0 text-start'
contentHeight='min(74vh, 760px)' contentHeight='min(74vh, 760px)'
bodyClassName='space-y-4' bodyClassName='space-y-4'
......
...@@ -212,7 +212,7 @@ export function DynamicPricingBreakdown({ ...@@ -212,7 +212,7 @@ export function DynamicPricingBreakdown({
</div> </div>
</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')} {t('Raw expression')}
</div> </div>
<code className='text-muted-foreground block text-xs break-all'> <code className='text-muted-foreground block text-xs break-all'>
...@@ -275,10 +275,7 @@ export function DynamicPricingBreakdown({ ...@@ -275,10 +275,7 @@ export function DynamicPricingBreakdown({
)} )}
> >
<div className='mb-1.5 flex flex-wrap items-center gap-1.5'> <div className='mb-1.5 flex flex-wrap items-center gap-1.5'>
<Badge <Badge variant='outline'>
variant='secondary'
className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
>
{tier.label || t('Default')} {tier.label || t('Default')}
</Badge> </Badge>
{isMatched && ( {isMatched && (
...@@ -302,12 +299,12 @@ export function DynamicPricingBreakdown({ ...@@ -302,12 +299,12 @@ export function DynamicPricingBreakdown({
) )
return ( return (
<div key={v.field} className='min-w-0'> <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)} {t(v.shortLabel)}
</div> </div>
<div <div
className={cn( className={cn(
'truncate font-mono', 'truncate tabular-nums',
compact ? 'text-xs' : 'text-sm font-semibold' compact ? 'text-xs' : 'text-sm font-semibold'
)} )}
> >
...@@ -357,10 +354,7 @@ export function DynamicPricingBreakdown({ ...@@ -357,10 +354,7 @@ export function DynamicPricingBreakdown({
return ( return (
<> <>
<div className='flex flex-wrap items-center gap-1.5'> <div className='flex flex-wrap items-center gap-1.5'>
<Badge <Badge variant='outline'>
variant='secondary'
className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
>
{tier.label || t('Default')} {tier.label || t('Default')}
</Badge> </Badge>
{isMatched && ( {isMatched && (
...@@ -389,7 +383,7 @@ export function DynamicPricingBreakdown({ ...@@ -389,7 +383,7 @@ export function DynamicPricingBreakdown({
compact && 'h-8' compact && 'h-8'
), ),
cellClassName: cn( cellClassName: cn(
'text-right align-top font-mono', 'text-right align-top tabular-nums',
compact ? 'py-2' : 'py-2.5' compact ? 'py-2' : 'py-2.5'
), ),
cell: (tier: ParsedTier) => { cell: (tier: ParsedTier) => {
......
...@@ -23,9 +23,5 @@ export { ModelCardGrid } from './model-card-grid' ...@@ -23,9 +23,5 @@ export { ModelCardGrid } from './model-card-grid'
export { LoadingSkeleton } from './loading-skeleton' export { LoadingSkeleton } from './loading-skeleton'
export { EmptyState } from './empty-state' export { EmptyState } from './empty-state'
export { SearchBar } from './search-bar' export { SearchBar } from './search-bar'
export { export { ModelDetails, ModelDetailsContent } from './model-details'
ModelDetails,
ModelDetailsContent,
ModelDetailsDrawer,
} from './model-details'
export { PricingTable } from './pricing-table' export { PricingTable } from './pricing-table'
...@@ -18,28 +18,55 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,28 +18,55 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { Skeleton } from '@/components/ui/skeleton' 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 { export interface LoadingSkeletonProps {
viewMode?: ViewMode viewMode?: ViewMode
} }
export function LoadingSkeleton(props: LoadingSkeletonProps) { export function LoadingSkeleton(props: LoadingSkeletonProps) {
const viewMode = props.viewMode ?? VIEW_MODES.CARD const viewMode = props.viewMode ?? DEFAULT_VIEW_MODE
return ( return (
<div className='space-y-5'> <div>
<div className='space-y-1.5'> <div className='mb-8 max-w-3xl space-y-2'>
<Skeleton className='h-8 w-40' /> <Skeleton className='h-6 w-48' />
<Skeleton className='h-4 w-52' /> <Skeleton className='h-4 w-full max-w-xl' />
</div>
<div className='space-y-4'>
<FilterBarSkeleton />
{viewMode === VIEW_MODES.TABLE ? (
<TableContentSkeleton />
) : (
<CardContentSkeleton />
)}
</div> </div>
<Skeleton className='h-10 w-full rounded-lg' />
<FilterBarSkeleton />
{viewMode === VIEW_MODES.TABLE ? (
<TableContentSkeleton />
) : (
<CardContentSkeleton />
)}
</div> </div>
) )
} }
...@@ -47,11 +74,11 @@ export function LoadingSkeleton(props: LoadingSkeletonProps) { ...@@ -47,11 +74,11 @@ export function LoadingSkeleton(props: LoadingSkeletonProps) {
function CardContentSkeleton() { function CardContentSkeleton() {
return ( return (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'> <div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
{Array.from({ length: 9 }).map((_, i) => ( {CARD_SKELETONS.map((key) => (
<div key={i} className='rounded-xl border p-5'> <div key={key} className='rounded-lg border p-4'>
<div className='flex items-start justify-between gap-3'> <div className='flex items-start justify-between gap-3'>
<div className='flex min-w-0 items-start 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'> <div className='min-w-0 flex-1 space-y-2'>
<Skeleton className='h-5 w-36' /> <Skeleton className='h-5 w-36' />
<Skeleton className='h-3.5 w-48' /> <Skeleton className='h-3.5 w-48' />
...@@ -80,64 +107,41 @@ function CardContentSkeleton() { ...@@ -80,64 +107,41 @@ function CardContentSkeleton() {
function FilterBarSkeleton() { function FilterBarSkeleton() {
return ( return (
<div className='space-y-3'> <div>
<div className='flex items-center gap-3'> <div className='flex flex-col gap-3 sm:flex-row sm:items-center'>
<div className='flex flex-1 flex-wrap items-center gap-2'> <Skeleton className='h-7 w-full sm:h-8 sm:max-w-sm' />
{[80, 90, 75, 85, 70].map((width, i) => ( <div className='flex flex-wrap items-center gap-2 sm:ml-auto'>
<Skeleton <Skeleton className='h-7 w-20 sm:h-8' />
key={i} <Skeleton className='h-7 w-24 sm:h-8' />
className='h-8 rounded-lg' <Skeleton className='h-7 w-28 sm:h-8' />
style={{ width: `${width}px` }} <Skeleton className='h-7 w-16 sm:h-8' />
/>
))}
</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>
</div> </div>
<Skeleton className='h-5 w-24' /> <Skeleton className='mt-3 h-4 w-24' />
</div> </div>
) )
} }
function TableContentSkeleton() { function TableContentSkeleton() {
const columns = [
{ width: 200 },
{ width: 100 },
{ width: 100 },
{ width: 100 },
{ width: 80 },
{ width: 100 },
]
return ( return (
<div className='space-y-4'> <div className='space-y-4'>
<div className='overflow-hidden rounded-lg border'> <div className='overflow-hidden rounded-lg border'>
<div className='bg-muted/30 border-b px-4 py-3'> <div className='bg-muted/30 border-b px-4 py-3'>
<div className='flex items-center gap-4'> <div className='grid grid-cols-[minmax(200px,2fr)_repeat(3,minmax(100px,1fr))_minmax(120px,1fr)] gap-4'>
{columns.map((col, i) => ( <Skeleton className='h-4 w-32' />
<Skeleton {PRICE_COLUMNS.map((column) => (
key={i} <Skeleton key={column} className='h-4 w-20' />
className='h-4'
style={{ width: `${col.width}px` }}
/>
))} ))}
</div> </div>
</div> </div>
{Array.from({ length: 10 }).map((_, i) => ( {TABLE_ROWS.map((row) => (
<div <div
key={i} key={row}
className='flex items-center gap-4 border-b px-4 py-3 last:border-b-0' 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 className='h-5 w-40' />
<Skeleton {PRICE_COLUMNS.map((column) => (
key={j} <Skeleton key={`${row}-${column}`} className='h-5 w-20' />
className='h-5'
style={{ width: `${col.width}px` }}
/>
))} ))}
</div> </div>
))} ))}
...@@ -145,8 +149,8 @@ function TableContentSkeleton() { ...@@ -145,8 +149,8 @@ function TableContentSkeleton() {
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<Skeleton className='h-5 w-32' /> <Skeleton className='h-5 w-32' />
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
{Array.from({ length: 4 }).map((_, i) => ( {PAGINATION_ITEMS.map((item) => (
<Skeleton key={i} className='size-8' /> <Skeleton key={item} className='size-8' />
))} ))}
</div> </div>
</div> </div>
......
...@@ -18,13 +18,16 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,13 +18,16 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight } from 'lucide-react' import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button' import { Button } from '@/components/design-system/button'
import { getPerfMetricsSummary } from '@/features/performance-metrics/api' 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 type { PricingModel, TokenUnit } from '../types'
import { ModelCard } from './model-card' import { ModelCard } from './model-card'
import type { ModelPerfBadgeData } from './model-perf-badge' import type { ModelPerfBadgeData } from './model-perf-badge'
...@@ -42,11 +45,15 @@ export interface ModelCardGridProps { ...@@ -42,11 +45,15 @@ export interface ModelCardGridProps {
export function ModelCardGrid(props: ModelCardGridProps) { export function ModelCardGrid(props: ModelCardGridProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [page, setPage] = useState(1) 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 tokenUnit = props.tokenUnit ?? DEFAULT_TOKEN_UNIT
const totalPages = Math.max(1, Math.ceil(props.models.length / pageSize)) const totalPages = Math.max(1, Math.ceil(props.models.length / pageSize))
const currentPage = Math.min(page, totalPages) const currentPage = Math.min(page, totalPages)
useEffect(() => {
setPage(1)
}, [props.models])
const perfQuery = useQuery({ const perfQuery = useQuery({
queryKey: ['perf-metrics-summary', 24], queryKey: ['perf-metrics-summary', 24],
queryFn: () => getPerfMetricsSummary(24), queryFn: () => getPerfMetricsSummary(24),
...@@ -73,7 +80,7 @@ export function ModelCardGrid(props: ModelCardGridProps) { ...@@ -73,7 +80,7 @@ export function ModelCardGrid(props: ModelCardGridProps) {
return ( return (
<div className='space-y-4 sm:space-y-5'> <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) => ( {pagedModels.map((model) => (
<ModelCard <ModelCard
key={model.id ?? model.model_name} key={model.id ?? model.model_name}
...@@ -90,7 +97,7 @@ export function ModelCardGrid(props: ModelCardGridProps) { ...@@ -90,7 +97,7 @@ export function ModelCardGrid(props: ModelCardGridProps) {
</div> </div>
{totalPages > 1 && ( {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'> <p className='text-muted-foreground'>
{t('Page {{current}} of {{total}}', { {t('Page {{current}} of {{total}}', {
current: currentPage, current: currentPage,
...@@ -105,7 +112,7 @@ export function ModelCardGrid(props: ModelCardGridProps) { ...@@ -105,7 +112,7 @@ export function ModelCardGrid(props: ModelCardGridProps) {
disabled={currentPage <= 1} disabled={currentPage <= 1}
className='gap-1.5' className='gap-1.5'
> >
<ChevronLeft className='size-4' /> <ChevronLeft aria-hidden='true' />
{t('Previous page')} {t('Previous page')}
</Button> </Button>
<Button <Button
...@@ -118,7 +125,7 @@ export function ModelCardGrid(props: ModelCardGridProps) { ...@@ -118,7 +125,7 @@ export function ModelCardGrid(props: ModelCardGridProps) {
className='gap-1.5' className='gap-1.5'
> >
{t('Next page')} {t('Next page')}
<ChevronRight className='size-4' /> <ChevronRight aria-hidden='true' />
</Button> </Button>
</div> </div>
</div> </div>
......
...@@ -507,7 +507,7 @@ function CodeSamplesSection(props: { ...@@ -507,7 +507,7 @@ function CodeSamplesSection(props: {
<TabsTrigger <TabsTrigger
key={ep.type} key={ep.type}
value={ep.type} value={ep.type}
className='h-7 px-2.5 text-xs' className='px-2.5 text-xs'
> >
{ep.type} {ep.type}
</TabsTrigger> </TabsTrigger>
...@@ -523,7 +523,7 @@ function CodeSamplesSection(props: { ...@@ -523,7 +523,7 @@ function CodeSamplesSection(props: {
> >
<TabsList className='bg-muted/40 p-0.5'> <TabsList className='bg-muted/40 p-0.5'>
{(Object.keys(LANG_LABELS) as Lang[]).map((l) => ( {(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]} {LANG_LABELS[l]}
</TabsTrigger> </TabsTrigger>
))} ))}
...@@ -598,7 +598,7 @@ function SupportedParametersSection(props: { model: PricingModel }) { ...@@ -598,7 +598,7 @@ function SupportedParametersSection(props: { model: PricingModel }) {
cell: (p) => ( cell: (p) => (
<Badge <Badge
variant='secondary' 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} {p.type}
</Badge> </Badge>
......
...@@ -51,7 +51,7 @@ function StatCard(props: { ...@@ -51,7 +51,7 @@ function StatCard(props: {
const Icon = props.icon const Icon = props.icon
return ( return (
<div className='bg-background flex flex-col gap-1 rounded-lg border p-3'> <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' /> <Icon className='size-3' />
{props.label} {props.label}
</span> </span>
......
...@@ -21,7 +21,6 @@ import type { ReactNode } from 'react' ...@@ -21,7 +21,6 @@ import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button' import { Button } from '@/components/design-system/button'
import { Badge } from '@/components/ui/badge'
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
...@@ -101,10 +100,10 @@ function FilterChip(props: { ...@@ -101,10 +100,10 @@ function FilterChip(props: {
type='button' type='button'
onClick={props.onClick} onClick={props.onClick}
className={cn( 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 props.active
? 'border-foreground/30 bg-foreground/5 text-foreground shadow-sm' ? 'border-foreground/30 bg-muted text-foreground'
: 'border-border/70 bg-background text-muted-foreground hover:border-border hover:bg-muted/50 hover:text-foreground' : 'border-border bg-background text-muted-foreground hover:bg-muted/50 hover:text-foreground'
)} )}
title={props.option.label} title={props.option.label}
> >
...@@ -130,15 +129,15 @@ function FilterChip(props: { ...@@ -130,15 +129,15 @@ function FilterChip(props: {
function FilterSection(props: FilterSectionProps) { function FilterSection(props: FilterSectionProps) {
return ( return (
<Collapsible <Collapsible defaultOpen className='border-b pb-3 last:border-b-0'>
defaultOpen
className='border-border/70 border-b pb-3 last:border-b-0'
>
<CollapsibleTrigger className='group flex w-full items-center justify-between py-2.5 text-left'> <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} {props.title}
</span> </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> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<div className='flex flex-wrap gap-1.5'> <div className='flex flex-wrap gap-1.5'>
...@@ -246,50 +245,36 @@ export function PricingSidebar(props: PricingSidebarProps) { ...@@ -246,50 +245,36 @@ export function PricingSidebar(props: PricingSidebarProps) {
] ]
return ( return (
<aside className={cn('rounded-xl border p-3', props.className)}> <aside className={cn('rounded-lg border p-3', props.className)}>
<div className='mb-2.5 flex items-center justify-between gap-2'> <div className='mb-2 flex items-center justify-between gap-2'>
<div> <p className='text-muted-foreground text-xs'>
<h2 className='text-foreground text-sm font-bold'>{t('Filter')}</h2> {props.hasActiveFilters
<p className='text-muted-foreground mt-1 text-xs'> ? t('Filters active')
{t('Refine models by provider, group, type, and tags.')} : t('Refine models by provider, group, type, and tags.')}
</p> </p>
</div>
<Button <Button
type='button' type='button'
variant='ghost' variant='ghost'
size='sm'
onClick={props.onClearFilters} onClick={props.onClearFilters}
disabled={!props.hasActiveFilters} disabled={!props.hasActiveFilters}
> >
<RotateCcw className='size-3.5' /> <RotateCcw aria-hidden='true' />
{t('Reset')} {t('Reset')}
</Button> </Button>
</div> </div>
{props.hasActiveFilters && (
<Badge variant='secondary' className='mb-3'>
{t('Filters active')}
</Badge>
)}
<div className='space-y-1'> <div className='space-y-1'>
<FilterSection <FilterSection
title={t('Groups')}
value={props.groupFilter}
options={groupOptions}
onChange={props.onGroupChange}
/>
<FilterSection
title={t('All Vendors')} title={t('All Vendors')}
value={props.vendorFilter} value={props.vendorFilter}
options={vendorOptions} options={vendorOptions}
onChange={props.onVendorChange} onChange={props.onVendorChange}
/> />
<FilterSection <FilterSection
title={t('Model Tags')} title={t('Endpoint Type')}
value={props.tagFilter} value={props.endpointTypeFilter}
options={tagOptions} options={endpointOptions}
onChange={props.onTagChange} onChange={props.onEndpointTypeChange}
/> />
<FilterSection <FilterSection
title={t('Pricing Type')} title={t('Pricing Type')}
...@@ -298,10 +283,16 @@ export function PricingSidebar(props: PricingSidebarProps) { ...@@ -298,10 +283,16 @@ export function PricingSidebar(props: PricingSidebarProps) {
onChange={props.onQuotaTypeChange} onChange={props.onQuotaTypeChange}
/> />
<FilterSection <FilterSection
title={t('Endpoint Type')} title={t('Groups')}
value={props.endpointTypeFilter} value={props.groupFilter}
options={endpointOptions} options={groupOptions}
onChange={props.onEndpointTypeChange} onChange={props.onGroupChange}
/>
<FilterSection
title={t('Model Tags')}
value={props.tagFilter}
options={tagOptions}
onChange={props.onTagChange}
/> />
</div> </div>
</aside> </aside>
......
...@@ -16,8 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,8 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useQuery } from '@tanstack/react-query'
import type { Row, PaginationState } from '@tanstack/react-table' 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 { useTranslation } from 'react-i18next'
import { import {
...@@ -26,9 +27,11 @@ import { ...@@ -26,9 +27,11 @@ import {
DataTableView, DataTableView,
useDataTable, useDataTable,
} from '@/components/data-table' } from '@/components/data-table'
import { getPerfMetricsSummary } from '@/features/performance-metrics/api'
import { DEFAULT_PRICING_PAGE_SIZE, DEFAULT_TOKEN_UNIT } from '../constants' import { DEFAULT_PRICING_PAGE_SIZE, DEFAULT_TOKEN_UNIT } from '../constants'
import type { PricingModel, TokenUnit } from '../types' import type { PricingModel, TokenUnit } from '../types'
import type { ModelPerfBadgeData } from './model-perf-badge'
import { usePricingColumns } from './pricing-columns' import { usePricingColumns } from './pricing-columns'
export interface PricingTableProps { export interface PricingTableProps {
...@@ -60,12 +63,34 @@ export function PricingTable(props: PricingTableProps) { ...@@ -60,12 +63,34 @@ export function PricingTable(props: PricingTableProps) {
pageSize: DEFAULT_PRICING_PAGE_SIZE, 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({ const columns = usePricingColumns({
tokenUnit, tokenUnit,
priceRate, priceRate,
usdExchangeRate, usdExchangeRate,
showRechargePrice, showRechargePrice,
selectedGroup, selectedGroup,
perfMap,
}) })
const { table } = useDataTable({ const { table } = useDataTable({
...@@ -87,6 +112,15 @@ export function PricingTable(props: PricingTableProps) { ...@@ -87,6 +112,15 @@ export function PricingTable(props: PricingTableProps) {
[onModelClick] [onModelClick]
) )
const handleRowKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTableRowElement>, model: PricingModel) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
handleRowClick(model)
},
[handleRowClick]
)
return ( return (
<div className='space-y-4'> <div className='space-y-4'>
<DataTableView <DataTableView
...@@ -103,8 +137,11 @@ export function PricingTable(props: PricingTableProps) { ...@@ -103,8 +137,11 @@ export function PricingTable(props: PricingTableProps) {
<DataTableRow <DataTableRow
key={row.id} key={row.id}
row={row} 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)} onClick={() => handleRowClick(row.original)}
onKeyDown={(event) => handleRowKeyDown(event, row.original)}
/> />
)} )}
/> />
......
...@@ -21,6 +21,7 @@ import { useEffect, useRef } from 'react' ...@@ -21,6 +21,7 @@ import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button' import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export interface SearchBarProps { export interface SearchBarProps {
...@@ -51,35 +52,33 @@ export function SearchBar(props: SearchBarProps) { ...@@ -51,35 +52,33 @@ export function SearchBar(props: SearchBarProps) {
return ( return (
<div className={cn('relative', props.className)}> <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' /> <Search
<input 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} ref={inputRef}
type='text' type='search'
placeholder={props.placeholder || t('Search models...')} placeholder={props.placeholder || t('Search models...')}
value={props.value} value={props.value}
onChange={(e) => props.onChange(e.target.value)} onChange={(e) => props.onChange(e.target.value)}
className={cn( className='bg-background w-full pr-14 pl-8 [&::-webkit-search-cancel-button]:hidden'
'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'
)}
aria-label={t('Search models')} 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 ? ( {props.value ? (
<Button <Button
variant='ghost' variant='ghost'
size='icon-sm' size='icon-xs'
onClick={props.onClear} onClick={props.onClear}
className='text-muted-foreground/60 hover:text-foreground' className='text-muted-foreground hover:text-foreground'
aria-label={t('Clear search')} aria-label={t('Clear search')}
> >
<X className='size-4' /> <X aria-hidden='true' className='size-3.5' />
</Button> </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'> <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'>
K Ctrl K
</kbd> </kbd>
)} )}
</div> </div>
......
...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { type TFunction } from 'i18next' import type { TFunction } from 'i18next'
import type { TokenUnit } from './types' import type { TokenUnit } from './types'
...@@ -141,5 +141,11 @@ export const VIEW_MODES = { ...@@ -141,5 +141,11 @@ export const VIEW_MODES = {
export type ViewMode = (typeof VIEW_MODES)[keyof typeof 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 */ /** Default page size for pricing table */
export const DEFAULT_PRICING_PAGE_SIZE = 20 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 { ...@@ -25,6 +25,7 @@ import {
QUOTA_TYPES, QUOTA_TYPES,
ENDPOINT_TYPES, ENDPOINT_TYPES,
DEFAULT_TOKEN_UNIT, DEFAULT_TOKEN_UNIT,
DEFAULT_VIEW_MODE,
VIEW_MODES, VIEW_MODES,
type ViewMode, type ViewMode,
} from '../constants' } from '../constants'
...@@ -45,10 +46,10 @@ type FilterState = { ...@@ -45,10 +46,10 @@ type FilterState = {
} }
function normalizeViewMode(value: unknown): ViewMode { function normalizeViewMode(value: unknown): ViewMode {
if (value === VIEW_MODES.TABLE) { if (value === VIEW_MODES.CARD) {
return VIEW_MODES.TABLE return VIEW_MODES.CARD
} }
return VIEW_MODES.CARD return DEFAULT_VIEW_MODE
} }
export function useFilters(models: PricingModel[]) { export function useFilters(models: PricingModel[]) {
...@@ -130,7 +131,7 @@ export function useFilters(models: PricingModel[]) { ...@@ -130,7 +131,7 @@ export function useFilters(models: PricingModel[]) {
) )
const setViewMode = useCallback( const setViewMode = useCallback(
(v: ViewMode) => (v: ViewMode) =>
updateFilters({ view: v === VIEW_MODES.CARD ? undefined : v }), updateFilters({ view: v === DEFAULT_VIEW_MODE ? undefined : v }),
[updateFilters] [updateFilters]
) )
const setShowRechargePrice = useCallback( const setShowRechargePrice = useCallback(
...@@ -186,6 +187,37 @@ export function useFilters(models: PricingModel[]) { ...@@ -186,6 +187,37 @@ export function useFilters(models: PricingModel[]) {
[vendorFilter, groupFilter, quotaTypeFilter, endpointTypeFilter, tagFilter] [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(() => { const clearFilters = useCallback(() => {
updateFilters({ updateFilters({
vendor: undefined, vendor: undefined,
...@@ -225,6 +257,7 @@ export function useFilters(models: PricingModel[]) { ...@@ -225,6 +257,7 @@ export function useFilters(models: PricingModel[]) {
hasActiveFilters, hasActiveFilters,
activeFilterCount, activeFilterCount,
availableTags, availableTags,
routeSearch,
clearFilters, clearFilters,
clearSearch, clearSearch,
} }
......
...@@ -18,7 +18,8 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,7 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { useTranslation } from 'react-i18next' 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' import type { RankingPeriod } from '../types'
...@@ -34,60 +35,45 @@ type RankingsHeroProps = { ...@@ -34,60 +35,45 @@ type RankingsHeroProps = {
onPeriodChange: (period: RankingPeriod) => void onPeriodChange: (period: RankingPeriod) => void
} }
/**
* Hero strip for the rankings page. Intentionally minimal — title +
* subtitle + period tabs only.
*/
export function RankingsHero(props: RankingsHeroProps) { export function RankingsHero(props: RankingsHeroProps) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<section className='space-y-5'> <PublicPageHeader
<div className='space-y-2'> title={t('Rankings')}
<h1 className='text-[clamp(1.75rem,4vw,2.5rem)] leading-[1.15] font-bold tracking-tight'> description={t(
{t('Rankings')} 'Discover the most-used models and rising vendors on the platform, updated from live usage data.'
</h1> )}
<p className='text-muted-foreground/80 max-w-2xl text-sm'> >
{t( <Tabs
'Discover the most-used models and rising vendors on the platform, updated from live usage data.' value={props.period}
)} onValueChange={(value) => {
</p> if (
</div> value === 'today' ||
value === 'week' ||
{/* Underline tabs for period — clean and unobtrusive. */} value === 'month' ||
<div value === 'year'
role='tablist' ) {
aria-label={t('Period')} props.onPeriodChange(value)
className='border-border/60 flex items-center border-b' }
}}
> >
{PERIODS.map((p) => { <TabsList
const isActive = props.period === p.id variant='line'
return ( aria-label={t('Period')}
<button className='w-full justify-start gap-6 overflow-x-auto overflow-y-hidden border-b p-0'
key={p.id} >
role='tab' {PERIODS.map((period) => (
type='button' <TabsTrigger
aria-selected={isActive} key={period.id}
onClick={() => props.onPeriodChange(p.id)} value={period.id}
className={cn( className='flex-none px-0.5 pb-3'
'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'
)}
> >
{t(p.labelKey)} {t(period.labelKey)}
<span </TabsTrigger>
aria-hidden ))}
className={cn( </TabsList>
'bg-foreground absolute inset-x-3 -bottom-px h-[2px] rounded-full transition-opacity', </Tabs>
isActive ? 'opacity-100' : 'opacity-0' </PublicPageHeader>
)}
/>
</button>
)
})}
</div>
</section>
) )
} }
...@@ -17,10 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,10 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useNavigate, useSearch } from '@tanstack/react-router' import { useNavigate, useSearch } from '@tanstack/react-router'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { PublicLayout } from '@/components/layout' import { PublicLayout, PublicPageShell } from '@/components/layout'
import { PageTransition } from '@/components/page-transition'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { import {
...@@ -32,14 +32,14 @@ import { ...@@ -32,14 +32,14 @@ import {
import { useRankings } from './hooks/use-rankings' import { useRankings } from './hooks/use-rankings'
import type { RankingPeriod } from './types' 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() { export function Rankings() {
const { t } = useTranslation() const { t } = useTranslation()
const search = useSearch({ from: '/rankings/' }) const search = useSearch({ from: '/rankings/' })
const navigate = useNavigate() const navigate = useNavigate()
const period: RankingPeriod = VALID_PERIODS.includes( const period: RankingPeriod = VALID_PERIODS.has(
search.period as RankingPeriod search.period as RankingPeriod
) )
? (search.period as RankingPeriod) ? (search.period as RankingPeriod)
...@@ -55,59 +55,48 @@ export function Rankings() { ...@@ -55,59 +55,48 @@ export function Rankings() {
}) })
} }
return ( let rankingsBody: ReactNode
<PublicLayout showMainContainer={false}> if (rankingsQuery.isLoading) {
<div className='relative'> rankingsBody = <RankingsLoading />
<div } else if (!snapshot) {
aria-hidden rankingsBody = (
className='pointer-events-none absolute inset-x-0 top-0 h-[600px] opacity-20 dark:opacity-[0.10]' <RankingsError
style={{ message={
background: [ rankingsQuery.error instanceof Error
'radial-gradient(ellipse 60% 50% at 20% 20%, oklch(0.72 0.18 250 / 80%) 0%, transparent 70%)', ? rankingsQuery.error.message
'radial-gradient(ellipse 50% 40% at 80% 15%, oklch(0.65 0.15 200 / 60%) 0%, transparent 70%)', : t('Unable to load rankings data')
'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%)', } else {
WebkitMaskImage: rankingsBody = (
'linear-gradient(to bottom, black 40%, transparent 100%)', <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> </div>
)
}
return (
<PublicLayout showMainContainer={false}>
<PublicPageShell>
<RankingsHero period={period} onPeriodChange={handlePeriodChange} />
{rankingsBody}
</PublicPageShell>
</PublicLayout> </PublicLayout>
) )
} }
......
...@@ -440,7 +440,7 @@ export function AnnouncementsSection({ ...@@ -440,7 +440,7 @@ export function AnnouncementsSection({
description={t( description={t(
'Create or update system announcements for the dashboard' 'Create or update system announcements for the dashboard'
)} )}
contentClassName='max-w-2xl' contentClassName='sm:max-w-2xl'
contentHeight='auto' contentHeight='auto'
bodyClassName='space-y-4' bodyClassName='space-y-4'
footer={ footer={
......
...@@ -322,7 +322,7 @@ export function FAQSection({ enabled, data }: FAQSectionProps) { ...@@ -322,7 +322,7 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
onOpenChange={setShowDialog} onOpenChange={setShowDialog}
title={editingFaq ? t('Edit FAQ') : t('Add FAQ')} title={editingFaq ? t('Edit FAQ') : t('Add FAQ')}
description={t('Create or update frequently asked questions for users')} description={t('Create or update frequently asked questions for users')}
contentClassName='max-w-2xl' contentClassName='sm:max-w-2xl'
contentHeight='auto' contentHeight='auto'
bodyClassName='space-y-4' bodyClassName='space-y-4'
footer={ footer={
......
...@@ -245,7 +245,7 @@ export function RuleEditorDialog(props: Props) { ...@@ -245,7 +245,7 @@ export function RuleEditorDialog(props: Props) {
open={props.open} open={props.open}
onOpenChange={props.onOpenChange} onOpenChange={props.onOpenChange}
title={isEdit ? t('Edit Rule') : t('Add Rule')} title={isEdit ? t('Edit Rule') : t('Add Rule')}
contentClassName='max-w-2xl' contentClassName='sm:max-w-2xl'
contentHeight='auto' contentHeight='auto'
bodyClassName='pr-2' bodyClassName='pr-2'
footer={ footer={
......
...@@ -55,7 +55,7 @@ export function ConflictConfirmDialog({ ...@@ -55,7 +55,7 @@ export function ConflictConfirmDialog({
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className='max-w-4xl'> <AlertDialogContent className='sm:max-w-4xl'>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{t('Confirm Billing Conflicts')}</AlertDialogTitle> <AlertDialogTitle>{t('Confirm Billing Conflicts')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
......
...@@ -25,8 +25,8 @@ import { ...@@ -25,8 +25,8 @@ import {
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
DataTableCardDetails,
DataTableCardField, DataTableCardField,
DataTableCardRow,
MobileCardList, MobileCardList,
} from '@/components/data-table' } from '@/components/data-table'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
...@@ -73,76 +73,76 @@ function orderCardCells<TData>( ...@@ -73,76 +73,76 @@ function orderCardCells<TData>(
}) })
} }
function CardCellField<TData>(props: { function isWideField<TData>(cell: Cell<TData, unknown>): boolean {
cell: Cell<TData, unknown> const meta = cell.column.columnDef.meta
hideLabel?: boolean return meta?.cardSpan === 2 || meta?.contentMode === 'summary'
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 UsageLogCard<TData>(props: { cells: Cell<TData, unknown>[] }) { function UsageLogCard<TData>(props: { cells: Cell<TData, unknown>[] }) {
const titleCell = props.cells.find((cell) => getCardRole(cell) === 'title') const titleCell = props.cells.find((cell) => getCardRole(cell) === 'title')
const badgeCell = props.cells.find((cell) => getCardRole(cell) === 'badge') const badgeCell = props.cells.find((cell) => getCardRole(cell) === 'badge')
const primaryCells = orderCardCells( const bodyCells = orderCardCells(
props.cells.filter((cell) => getCardRole(cell) === 'primary') props.cells.filter(
) (cell) =>
const secondaryCells = orderCardCells( getCardRole(cell) !== 'title' &&
props.cells.filter((cell) => getCardRole(cell) === 'secondary') getCardRole(cell) !== 'badge' &&
getCardRole(cell) !== 'hidden'
)
) )
const rowCells = bodyCells.filter((cell) => !isWideField(cell))
const wideCells = bodyCells.filter((cell) => isWideField(cell))
return ( return (
<> <div className='flex min-w-0 flex-col'>
{(titleCell || badgeCell) && ( {(titleCell || badgeCell) && (
<div className='flex min-w-0 items-start justify-between gap-3'> <div className='flex min-w-0 items-start justify-between gap-3'>
{titleCell && ( {titleCell && (
<CardCellField <div className='min-w-0 flex-1 text-[15px] leading-tight font-semibold break-words'>
cell={titleCell} {flexRender(
hideLabel titleCell.column.columnDef.cell,
className='min-w-0 flex-1' titleCell.getContext()
valueClassName='font-medium' )}
/> </div>
)} )}
{badgeCell && ( {badgeCell && (
<CardCellField <div className='max-w-1/2 shrink text-right tabular-nums'>
cell={badgeCell} {flexRender(
hideLabel badgeCell.column.columnDef.cell,
className='max-w-1/2 shrink text-right' badgeCell.getContext()
valueClassName='flex justify-end text-right tabular-nums' )}
/> </div>
)} )}
</div> </div>
)} )}
{primaryCells.length > 0 && ( {rowCells.length > 0 && (
<div className='mt-2 grid grid-cols-2 gap-x-3 gap-y-2'> <div className='mt-3 space-y-0.5 border-t pt-3'>
{primaryCells.map((cell) => ( {rowCells.map((cell) => (
<CardCellField key={cell.id} cell={cell} /> <DataTableCardRow
key={cell.id}
label={getCardLabel(cell)}
contentMode={cell.column.columnDef.meta?.contentMode}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataTableCardRow>
))} ))}
</div> </div>
)} )}
{secondaryCells.length > 0 && ( {wideCells.length > 0 && (
<DataTableCardDetails count={secondaryCells.length}> <div className='mt-3 space-y-3 border-t pt-3'>
{secondaryCells.map((cell) => ( {wideCells.map((cell) => (
<CardCellField key={cell.id} cell={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 @@ ...@@ -395,6 +395,7 @@
"API Key mode: use APIKey|Region": "API Key mode: use APIKey|Region", "API Key mode: use APIKey|Region": "API Key mode: use APIKey|Region",
"API Key updated successfully": "API Key updated successfully", "API Key updated successfully": "API Key updated successfully",
"API Keys": "API Keys", "API Keys": "API Keys",
"API pricing": "API pricing",
"API Private Key": "API Private Key", "API Private Key": "API Private Key",
"API Requests": "API Requests", "API Requests": "API Requests",
"API secret": "API secret", "API secret": "API secret",
...@@ -2662,6 +2663,7 @@ ...@@ -2662,6 +2663,7 @@
"Models": "Models", "Models": "Models",
"Models *": "Models *", "Models *": "Models *",
"Models & Groups": "Models & Groups", "Models & Groups": "Models & Groups",
"Models & pricing": "Models & pricing",
"Models & Routing": "Models & Routing", "Models & Routing": "Models & Routing",
"Models appended successfully": "Models appended successfully", "Models appended successfully": "Models appended successfully",
"Models are required": "Models are required", "Models are required": "Models are required",
...@@ -4379,6 +4381,7 @@ ...@@ -4379,6 +4381,7 @@
"Text or array of texts to embed": "Text or array of texts to embed", "Text or array of texts to embed": "Text or array of texts to embed",
"Text Output": "Text Output", "Text Output": "Text Output",
"Text to Video": "Text to Video", "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 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 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.", "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 @@ ...@@ -395,6 +395,7 @@
"API Key mode: use APIKey|Region": "Mode clé API : utiliser APIKey|Region", "API Key mode: use APIKey|Region": "Mode clé API : utiliser APIKey|Region",
"API Key updated successfully": "Clé API mise à jour avec succès", "API Key updated successfully": "Clé API mise à jour avec succès",
"API Keys": "Clés API", "API Keys": "Clés API",
"API pricing": "Tarification de l’API",
"API Private Key": "Clé privée de l'API", "API Private Key": "Clé privée de l'API",
"API Requests": "Requêtes API", "API Requests": "Requêtes API",
"API secret": "Secret API", "API secret": "Secret API",
...@@ -2662,6 +2663,7 @@ ...@@ -2662,6 +2663,7 @@
"Models": "Modèles", "Models": "Modèles",
"Models *": "Modèles *", "Models *": "Modèles *",
"Models & Groups": "Modèles & Groupes", "Models & Groups": "Modèles & Groupes",
"Models & pricing": "Modèles et tarification",
"Models & Routing": "Modèles et routage", "Models & Routing": "Modèles et routage",
"Models appended successfully": "Modèles ajoutés avec succès", "Models appended successfully": "Modèles ajoutés avec succès",
"Models are required": "Les modèles sont requis", "Models are required": "Les modèles sont requis",
...@@ -4379,6 +4381,7 @@ ...@@ -4379,6 +4381,7 @@
"Text or array of texts to embed": "Texte ou tableau de textes à vectoriser", "Text or array of texts to embed": "Texte ou tableau de textes à vectoriser",
"Text Output": "Sortie texte", "Text Output": "Sortie texte",
"Text to Video": "Texte vers vidéo", "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 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 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.", "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 @@ ...@@ -395,6 +395,7 @@
"API Key mode: use APIKey|Region": "APIキーモード: use APIKey | Region", "API Key mode: use APIKey|Region": "APIキーモード: use APIKey | Region",
"API Key updated successfully": "APIキーが正常に更新されました", "API Key updated successfully": "APIキーが正常に更新されました",
"API Keys": "APIキー", "API Keys": "APIキー",
"API pricing": "API 料金",
"API Private Key": "API 秘密鍵", "API Private Key": "API 秘密鍵",
"API Requests": "APIリクエスト", "API Requests": "APIリクエスト",
"API secret": "APIシークレット", "API secret": "APIシークレット",
...@@ -2662,6 +2663,7 @@ ...@@ -2662,6 +2663,7 @@
"Models": "モデル", "Models": "モデル",
"Models *": "モデル *", "Models *": "モデル *",
"Models & Groups": "モデルとグループ", "Models & Groups": "モデルとグループ",
"Models & pricing": "モデルと料金",
"Models & Routing": "モデルとルーティング", "Models & Routing": "モデルとルーティング",
"Models appended successfully": "モデルが正常に追加されました", "Models appended successfully": "モデルが正常に追加されました",
"Models are required": "モデルが必要です", "Models are required": "モデルが必要です",
...@@ -4379,6 +4381,7 @@ ...@@ -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": "テキストトークン",
"The admin configured three groups and one special ratio rule:": "管理者は3つのグループと1つの特別倍率ルールを設定しました:", "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 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.": "管理者アカウントはすでに初期化されています。既存の認証情報を保持して、次のステップに進むことができます。", "The administrator account is already initialized. You can keep your existing credentials and continue to the next step.": "管理者アカウントはすでに初期化されています。既存の認証情報を保持して、次のステップに進むことができます。",
......
...@@ -395,6 +395,7 @@ ...@@ -395,6 +395,7 @@
"API Key mode: use APIKey|Region": "Режим API Key: use APIKey|Region", "API Key mode: use APIKey|Region": "Режим API Key: use APIKey|Region",
"API Key updated successfully": "API ключ успешно обновлен", "API Key updated successfully": "API ключ успешно обновлен",
"API Keys": "Ключи API", "API Keys": "Ключи API",
"API pricing": "Тарифы API",
"API Private Key": "Секретный ключ API", "API Private Key": "Секретный ключ API",
"API Requests": "Запросы API", "API Requests": "Запросы API",
"API secret": "Секрет API", "API secret": "Секрет API",
...@@ -2662,6 +2663,7 @@ ...@@ -2662,6 +2663,7 @@
"Models": "Модели", "Models": "Модели",
"Models *": "Модели *", "Models *": "Модели *",
"Models & Groups": "Модели и группы", "Models & Groups": "Модели и группы",
"Models & pricing": "Модели и тарифы",
"Models & Routing": "Модели и маршрутизация", "Models & Routing": "Модели и маршрутизация",
"Models appended successfully": "Модели успешно добавлены", "Models appended successfully": "Модели успешно добавлены",
"Models are required": "Требуются модели", "Models are required": "Требуются модели",
...@@ -4379,6 +4381,7 @@ ...@@ -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": "Текстовые токены",
"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.": "Администратор хочет, чтобы пользователи vip платили ещё меньше при использовании premium. Для этого нужно правило переопределения: в матрице задайте ячейку на пересечении строки vip и столбца premium равной 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.": "Администратор хочет, чтобы пользователи vip платили ещё меньше при использовании premium. Для этого нужно правило переопределения: в матрице задайте ячейку на пересечении строки vip и столбца premium равной 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 @@ ...@@ -395,6 +395,7 @@
"API Key mode: use APIKey|Region": "Chế độ khóa API: sử dụng APIKey|Region", "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 Key updated successfully": "API Key đã được cập nhật thành công",
"API Keys": "Khóa API", "API Keys": "Khóa API",
"API pricing": "Bảng giá API",
"API Private Key": "Khóa riêng API", "API Private Key": "Khóa riêng API",
"API Requests": "Yêu cầu API", "API Requests": "Yêu cầu API",
"API secret": "Bí mật API", "API secret": "Bí mật API",
...@@ -2662,6 +2663,7 @@ ...@@ -2662,6 +2663,7 @@
"Models": "Mô hình", "Models": "Mô hình",
"Models *": "Các mô hình *", "Models *": "Các mô hình *",
"Models & Groups": "Mô hình & Nhóm", "Models & Groups": "Mô hình & Nhóm",
"Models & pricing": "Mô hình và giá",
"Models & Routing": "Mô hình & định tuyến", "Models & Routing": "Mô hình & định tuyến",
"Models appended successfully": "Đã thêm mô hình thành công", "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", "Models are required": "Các mô hình được yêu cầu",
...@@ -4379,6 +4381,7 @@ ...@@ -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 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 Output": "Đầu ra văn bản",
"Text to Video": "Văn bản sang video", "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 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 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.", "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 @@ ...@@ -395,6 +395,7 @@
"API Key mode: use APIKey|Region": "API Key 模式:使用 APIKey|Region", "API Key mode: use APIKey|Region": "API Key 模式:使用 APIKey|Region",
"API Key updated successfully": "API 金鑰更新成功", "API Key updated successfully": "API 金鑰更新成功",
"API Keys": "API 金鑰", "API Keys": "API 金鑰",
"API pricing": "API 定價",
"API Private Key": "API 私鑰", "API Private Key": "API 私鑰",
"API Requests": "API 請求", "API Requests": "API 請求",
"API secret": "API 密鑰", "API secret": "API 密鑰",
...@@ -2002,7 +2003,7 @@ ...@@ -2002,7 +2003,7 @@
"footer.columns.related.links.oneApi": "One API", "footer.columns.related.links.oneApi": "One API",
"footer.columns.related.title": "相關項目", "footer.columns.related.title": "相關項目",
"footer.defaultCopyright": "版權所有。", "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 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", "For private deployments, format: https://fastgpt.run/api/openapi": "對於私有部署,格式為:https://fastgpt.run/api/openapi",
"Force a syntactically valid JSON response": "強制返回語法合法的 JSON", "Force a syntactically valid JSON response": "強制返回語法合法的 JSON",
...@@ -2662,6 +2663,7 @@ ...@@ -2662,6 +2663,7 @@
"Models": "模型", "Models": "模型",
"Models *": "模型 *", "Models *": "模型 *",
"Models & Groups": "模型與分組", "Models & Groups": "模型與分組",
"Models & pricing": "模型與定價",
"Models & Routing": "模型與路由", "Models & Routing": "模型與路由",
"Models appended successfully": "模型已追加成功", "Models appended successfully": "模型已追加成功",
"Models are required": "需要模型", "Models are required": "需要模型",
...@@ -4379,6 +4381,7 @@ ...@@ -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": "文字 Token",
"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.": "管理員希望 vip 用戶使用 premium 時價格更低。這就需要一條覆蓋規則:在覆蓋矩陣中,把「行 vip、列 premium」的單元格填成 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.": "管理員希望 vip 用戶使用 premium 時價格更低。這就需要一條覆蓋規則:在覆蓋矩陣中,把「行 vip、列 premium」的單元格填成 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 @@ ...@@ -395,6 +395,7 @@
"API Key mode: use APIKey|Region": "API Key 模式:使用 APIKey|Region", "API Key mode: use APIKey|Region": "API Key 模式:使用 APIKey|Region",
"API Key updated successfully": "API 密钥更新成功", "API Key updated successfully": "API 密钥更新成功",
"API Keys": "API 密钥", "API Keys": "API 密钥",
"API pricing": "API 定价",
"API Private Key": "API 私钥", "API Private Key": "API 私钥",
"API Requests": "API 请求", "API Requests": "API 请求",
"API secret": "API 秘钥", "API secret": "API 秘钥",
...@@ -2662,6 +2663,7 @@ ...@@ -2662,6 +2663,7 @@
"Models": "模型", "Models": "模型",
"Models *": "模型 *", "Models *": "模型 *",
"Models & Groups": "模型与分组", "Models & Groups": "模型与分组",
"Models & pricing": "模型与定价",
"Models & Routing": "模型与路由", "Models & Routing": "模型与路由",
"Models appended successfully": "模型已追加成功", "Models appended successfully": "模型已追加成功",
"Models are required": "需要模型", "Models are required": "需要模型",
...@@ -4379,6 +4381,7 @@ ...@@ -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": "文本 Token",
"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.": "管理员希望 vip 用户使用 premium 时价格更低。这就需要一条覆盖规则:在覆盖矩阵中,把「行 vip、列 premium」的单元格填成 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.": "管理员希望 vip 用户使用 premium 时价格更低。这就需要一条覆盖规则:在覆盖矩阵中,把「行 vip、列 premium」的单元格填成 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.": "管理员账户已初始化。您可以保留现有凭据并继续下一步。",
......
/*
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 ...@@ -19,13 +19,9 @@ For commercial licensing, please contact support@quantumnous.com
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css'; @import 'tw-animate-css';
@import 'shadcn/tailwind.css'; @import 'shadcn/tailwind.css';
@import '@fontsource-variable/public-sans'; /* Public Sans + Lora with `font-display: optional` (see fonts.css). Avoids
/* Editorial serif (Lora) backing the `serif` font axis and the Anthropic * the mid-session size jump from Fontsource's default `swap` policy. */
* preset's default typography. See `--font-serif` in theme.css for the @import './fonts.css';
* 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';
@import './theme.css'; @import './theme.css';
@import './theme-presets.css'; @import './theme-presets.css';
...@@ -54,7 +50,10 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -54,7 +50,10 @@ For commercial licensing, please contact support@quantumnous.com
scrollbar-color: var(--border) transparent; scrollbar-color: var(--border) transparent;
} }
html { 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 { body {
@apply bg-background text-foreground has-[div[data-variant='inset']]:bg-sidebar min-h-svh w-full; @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