mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-06 17:46:23 +00:00
fix(web): prevent list cell text and badge overflow (#5510)
* fix(ui): prevent table cell text overflow - add default truncation with hover details for text cells in shared and static data tables to prevent content from spilling into adjacent columns. - adjust API key group, model, and IP restriction columns to fix badge overlap and left alignment drift. - reuse a shared truncated cell component and add width constraints for composite badge cells. * fix(table): prevent badge content from overflowing columns - make table text and badge cells shrink within constrained columns so long values truncate instead of bleeding into adjacent cells. - add a shared BadgeCell wrapper to keep badge alignment consistent across API keys and other list pages. - update affected list views to use constrained wrappers for group, provider, pricing, OAuth, and API info values.
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
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 * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type BadgeCellProps = React.HTMLAttributes<HTMLDivElement>
|
||||||
|
|
||||||
|
export function BadgeCell({ className, ...props }: BadgeCellProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'-ml-1.5 flex max-w-full min-w-0 items-center gap-1 overflow-hidden [&_[data-slot=status-badge]]:max-w-full [&_[data-slot=status-badge]]:min-w-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,13 +17,13 @@ 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 * as React from 'react'
|
import * as React from 'react'
|
||||||
import { StatusBadgeList } from '@/components/status-badge'
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
|
import { StatusBadgeList } from '@/components/status-badge'
|
||||||
|
|
||||||
interface BadgeListCellProps {
|
interface BadgeListCellProps {
|
||||||
items: React.ReactNode[]
|
items: React.ReactNode[]
|
||||||
@@ -50,7 +50,7 @@ export function BadgeListCell({
|
|||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger render={<div className='-ml-1.5' />}>
|
<TooltipTrigger render={<div className='-ml-1.5 max-w-full' />}>
|
||||||
<StatusBadgeList
|
<StatusBadgeList
|
||||||
items={items}
|
items={items}
|
||||||
max={max}
|
max={max}
|
||||||
|
|||||||
@@ -17,8 +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 * as React from 'react'
|
import * as React from 'react'
|
||||||
import { flexRender, type Row } from '@tanstack/react-table'
|
import { flexRender, type Cell, type Row } from '@tanstack/react-table'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { TableCell, TableRow } from '@/components/ui/table'
|
import { TableCell, TableRow } from '@/components/ui/table'
|
||||||
|
import { TruncatedCell } from './truncated-cell'
|
||||||
import type { DataTableColumnClassName } from './types'
|
import type { DataTableColumnClassName } from './types'
|
||||||
|
|
||||||
type DataTableRowProps<TData> = {
|
type DataTableRowProps<TData> = {
|
||||||
@@ -42,9 +44,12 @@ function DataTableRowInner<TData>({
|
|||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
className={getColumnClassName?.(cell.column.id, 'cell')}
|
className={cn(
|
||||||
|
'max-w-full min-w-0 overflow-hidden',
|
||||||
|
getColumnClassName?.(cell.column.id, 'cell')
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{renderCellContent(cell)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -61,3 +66,28 @@ export const DataTableRow = React.memo(DataTableRowInner, (prev, next) => {
|
|||||||
prev.row.getIsSelected() === next.row.getIsSelected()
|
prev.row.getIsSelected() === next.row.getIsSelected()
|
||||||
)
|
)
|
||||||
}) as typeof DataTableRowInner
|
}) as typeof DataTableRowInner
|
||||||
|
|
||||||
|
function renderCellContent<TData>(cell: Cell<TData, unknown>) {
|
||||||
|
const content = flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||||
|
const textContent = getPrimitiveTextContent(content)
|
||||||
|
|
||||||
|
if (!textContent) return content
|
||||||
|
|
||||||
|
return <TruncatedCell tooltipContent={textContent}>{content}</TruncatedCell>
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimitiveTextContent(content: React.ReactNode): string | null {
|
||||||
|
if (typeof content === 'string' || typeof content === 'number') {
|
||||||
|
return String(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
React.isValidElement<{ children?: React.ReactNode }>(content) &&
|
||||||
|
(typeof content.props.children === 'string' ||
|
||||||
|
typeof content.props.children === 'number')
|
||||||
|
) {
|
||||||
|
return String(content.props.children)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/*
|
||||||
|
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 * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
type TruncatedCellProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
cellClassName?: string
|
||||||
|
className?: string
|
||||||
|
contentClassName?: string
|
||||||
|
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||||
|
tooltipClassName?: string
|
||||||
|
tooltipContent?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TruncatedCell({
|
||||||
|
children,
|
||||||
|
cellClassName,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
side = 'top',
|
||||||
|
tooltipClassName,
|
||||||
|
tooltipContent,
|
||||||
|
}: TruncatedCellProps) {
|
||||||
|
const content = tooltipContent ?? getTextContent(children)
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'block max-w-full min-w-0 truncate',
|
||||||
|
cellClassName,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'block max-w-full min-w-0 truncate',
|
||||||
|
cellClassName,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={cn('truncate', contentClassName)}>{children}</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side={side}
|
||||||
|
className={cn('max-w-xs break-all', tooltipClassName)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextContent(node: React.ReactNode): string {
|
||||||
|
if (typeof node === 'string' || typeof node === 'number') return String(node)
|
||||||
|
if (Array.isArray(node)) return node.map(getTextContent).join('')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
@@ -18,7 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
*/
|
*/
|
||||||
export { DataTablePagination } from './core/pagination'
|
export { DataTablePagination } from './core/pagination'
|
||||||
export { DataTableColumnHeader } from './core/column-header'
|
export { DataTableColumnHeader } from './core/column-header'
|
||||||
|
export { BadgeCell } from './core/badge-cell'
|
||||||
export { BadgeListCell } from './core/badge-list-cell'
|
export { BadgeListCell } from './core/badge-list-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'
|
||||||
export { DataTableBulkActions } from './toolbar/bulk-actions'
|
export { DataTableBulkActions } from './toolbar/bulk-actions'
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
|
import { TruncatedCell } from '../core/truncated-cell'
|
||||||
import { staticDataTableClassNames } from './static-data-table-classnames'
|
import { staticDataTableClassNames } from './static-data-table-classnames'
|
||||||
|
|
||||||
type StaticDataTableBaseProps = {
|
type StaticDataTableBaseProps = {
|
||||||
@@ -163,15 +164,47 @@ function StaticDataTableRow<TData>({
|
|||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={column.id}
|
key={column.id}
|
||||||
className={getStaticCellClassName(column, row, index)}
|
className={cn(
|
||||||
|
'max-w-full min-w-0 overflow-hidden',
|
||||||
|
getStaticCellClassName(column, row, index)
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{column.cell?.(row, index)}
|
{renderStaticCellContent(column, row, index)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderStaticCellContent<TData>(
|
||||||
|
column: StaticDataTableColumn<TData>,
|
||||||
|
row: TData,
|
||||||
|
index: number
|
||||||
|
) {
|
||||||
|
const content = column.cell?.(row, index)
|
||||||
|
const textContent = getPrimitiveTextContent(content)
|
||||||
|
|
||||||
|
if (!textContent) return content
|
||||||
|
|
||||||
|
return <TruncatedCell tooltipContent={textContent}>{content}</TruncatedCell>
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimitiveTextContent(content: React.ReactNode): string | null {
|
||||||
|
if (typeof content === 'string' || typeof content === 'number') {
|
||||||
|
return String(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
React.isValidElement<{ children?: React.ReactNode }>(content) &&
|
||||||
|
(typeof content.props.children === 'string' ||
|
||||||
|
typeof content.props.children === 'number')
|
||||||
|
) {
|
||||||
|
return String(content.props.children)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function getStaticCellClassName<TData>(
|
function getStaticCellClassName<TData>(
|
||||||
column: StaticDataTableColumn<TData>,
|
column: StaticDataTableColumn<TData>,
|
||||||
row: TData,
|
row: TData,
|
||||||
|
|||||||
+5
-3
@@ -60,6 +60,7 @@ export function GroupBadge(props: GroupBadgeProps) {
|
|||||||
ratio,
|
ratio,
|
||||||
copyable = false,
|
copyable = false,
|
||||||
showDot,
|
showDot,
|
||||||
|
className,
|
||||||
...badgeProps
|
...badgeProps
|
||||||
} = props
|
} = props
|
||||||
const groupName = group?.trim()
|
const groupName = group?.trim()
|
||||||
@@ -82,6 +83,7 @@ export function GroupBadge(props: GroupBadgeProps) {
|
|||||||
showDot={showDot ?? (isSpecialGroup ? false : undefined)}
|
showDot={showDot ?? (isSpecialGroup ? false : undefined)}
|
||||||
variant={isSpecialGroup ? 'neutral' : undefined}
|
variant={isSpecialGroup ? 'neutral' : undefined}
|
||||||
autoColor={isSpecialGroup ? undefined : groupName}
|
autoColor={isSpecialGroup ? undefined : groupName}
|
||||||
|
className={cn('min-w-0 shrink overflow-hidden', className)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,11 +92,11 @@ export function GroupBadge(props: GroupBadgeProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className='inline-flex items-center gap-2 text-xs'>
|
<span className='inline-flex max-w-full min-w-0 items-center gap-2 text-xs'>
|
||||||
{badge}
|
<span className='max-w-full min-w-0 overflow-hidden'>{badge}</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex h-5 items-center rounded-full px-1.5 font-mono text-xs leading-none font-medium tabular-nums',
|
'inline-flex h-5 shrink-0 items-center rounded-full px-1.5 font-mono text-xs leading-none font-medium tabular-nums',
|
||||||
getGroupRatioClassName(ratio)
|
getGroupRatioClassName(ratio)
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
+13
-5
@@ -22,6 +22,7 @@ import { type LucideIcon } from 'lucide-react'
|
|||||||
import { stringToColor } from '@/lib/colors'
|
import { stringToColor } from '@/lib/colors'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||||
|
|
||||||
export const dotColorMap = {
|
export const dotColorMap = {
|
||||||
success: 'bg-success',
|
success: 'bg-success',
|
||||||
warning: 'bg-warning',
|
warning: 'bg-warning',
|
||||||
@@ -81,7 +82,8 @@ export type StatusBadgeType = 'badge' | 'text' | 'underline'
|
|||||||
|
|
||||||
/** Context that lets ancestor components (e.g. MobileCardList field area)
|
/** Context that lets ancestor components (e.g. MobileCardList field area)
|
||||||
* override the badge type without modifying every call site. */
|
* override the badge type without modifying every call site. */
|
||||||
export const StatusBadgeTypeContext = React.createContext<StatusBadgeType>('badge')
|
export const StatusBadgeTypeContext =
|
||||||
|
React.createContext<StatusBadgeType>('badge')
|
||||||
|
|
||||||
const sizeMap = {
|
const sizeMap = {
|
||||||
sm: 'h-5 gap-1 px-1.5 text-xs leading-none',
|
sm: 'h-5 gap-1 px-1.5 text-xs leading-none',
|
||||||
@@ -153,15 +155,21 @@ export function StatusBadge({
|
|||||||
) : null)
|
) : null)
|
||||||
|
|
||||||
const isBadge = type === 'badge'
|
const isBadge = type === 'badge'
|
||||||
|
const title = copyable
|
||||||
|
? `Click to copy: ${copyText || label || ''}`
|
||||||
|
: label || undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-slot='status-badge'
|
data-slot='status-badge'
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex w-fit max-w-full shrink-0 items-center font-medium tracking-normal whitespace-nowrap transition-colors',
|
'inline-flex w-fit max-w-full min-w-0 shrink items-center font-medium tracking-normal whitespace-nowrap transition-colors',
|
||||||
isBadge
|
isBadge
|
||||||
? cn('rounded-4xl', sizeMap[size ?? 'sm'])
|
? cn('rounded-4xl', sizeMap[size ?? 'sm'])
|
||||||
: cn(textSizeMap[size ?? 'sm'], type === 'underline' && 'border-b border-current pb-px'),
|
: cn(
|
||||||
|
textSizeMap[size ?? 'sm'],
|
||||||
|
type === 'underline' && 'border-b border-current pb-px'
|
||||||
|
),
|
||||||
textColorMap[computedVariant],
|
textColorMap[computedVariant],
|
||||||
pulse && 'animate-pulse',
|
pulse && 'animate-pulse',
|
||||||
copyable &&
|
copyable &&
|
||||||
@@ -169,7 +177,7 @@ export function StatusBadge({
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
title={copyable ? `Click to copy: ${copyText || label || ''}` : undefined}
|
title={title}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{showDot && (
|
{showDot && (
|
||||||
@@ -221,7 +229,7 @@ export function StatusBadgeList<T>(props: StatusBadgeListProps<T>) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex max-w-full items-center gap-1 overflow-hidden',
|
'flex max-w-full min-w-0 items-center gap-1 overflow-hidden',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...domProps}
|
{...domProps}
|
||||||
|
|||||||
+4
-20
@@ -1,10 +1,5 @@
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import { TruncatedCell } from '@/components/data-table'
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip'
|
|
||||||
|
|
||||||
interface TruncatedTextProps {
|
interface TruncatedTextProps {
|
||||||
text: string
|
text: string
|
||||||
@@ -20,19 +15,8 @@ export function TruncatedText({
|
|||||||
side = 'top',
|
side = 'top',
|
||||||
}: TruncatedTextProps) {
|
}: TruncatedTextProps) {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delay={300}>
|
<TruncatedCell className={cn(maxWidth, className)} side={side}>
|
||||||
<Tooltip>
|
{text}
|
||||||
<TooltipTrigger
|
</TruncatedCell>
|
||||||
render={
|
|
||||||
<span className={cn('block truncate', maxWidth, className)} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side={side} className='max-w-xs break-all'>
|
|
||||||
{text}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -32,6 +32,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
|
import { BadgeCell } from '@/components/data-table'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { type ApiKey } from '../types'
|
import { type ApiKey } from '../types'
|
||||||
import { useApiKeys } from './api-keys-provider'
|
import { useApiKeys } from './api-keys-provider'
|
||||||
@@ -157,7 +158,12 @@ export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
|
|||||||
|
|
||||||
if (!apiKey.model_limits_enabled || !apiKey.model_limits) {
|
if (!apiKey.model_limits_enabled || !apiKey.model_limits) {
|
||||||
return (
|
return (
|
||||||
<StatusBadge label={t('Unlimited')} variant='neutral' copyable={false} />
|
<StatusBadge
|
||||||
|
label={t('Unlimited')}
|
||||||
|
variant='neutral'
|
||||||
|
copyable={false}
|
||||||
|
className='-ml-1.5'
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +171,7 @@ export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger render={<span />}>
|
<TooltipTrigger render={<BadgeCell />}>
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={t('{{count}} model(s)', { count: models.length })}
|
label={t('{{count}} model(s)', { count: models.length })}
|
||||||
variant='neutral'
|
variant='neutral'
|
||||||
@@ -195,6 +201,7 @@ export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
|
|||||||
label={t('No restriction')}
|
label={t('No restriction')}
|
||||||
variant='neutral'
|
variant='neutral'
|
||||||
copyable={false}
|
copyable={false}
|
||||||
|
className='-ml-1.5'
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -206,7 +213,7 @@ export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger render={<span />}>
|
<TooltipTrigger render={<BadgeCell />}>
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={t('{{count}} IP(s)', { count: ips.length })}
|
label={t('{{count}} IP(s)', { count: ips.length })}
|
||||||
variant='neutral'
|
variant='neutral'
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
|
import { BadgeCell, TruncatedCell } from '@/components/data-table'
|
||||||
import { GroupBadge } from '@/components/group-badge'
|
import { GroupBadge } from '@/components/group-badge'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { API_KEY_STATUSES } from '../constants'
|
import { API_KEY_STATUSES } from '../constants'
|
||||||
@@ -97,9 +98,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
|||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: t('Name'),
|
header: t('Name'),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className='max-w-[200px] truncate font-medium'>
|
<span className='font-medium'>{row.getValue('name')}</span>
|
||||||
{row.getValue('name')}
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
size: 180,
|
size: 180,
|
||||||
meta: { mobileTitle: true },
|
meta: { mobileTitle: true },
|
||||||
@@ -200,9 +199,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
|||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
render={<BadgeCell className='gap-1.5 text-xs' />}
|
||||||
<span className='inline-flex items-center gap-1.5 text-xs' />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<GroupBadge group='auto' />
|
<GroupBadge group='auto' />
|
||||||
{apiKey.cross_group_retry && (
|
{apiKey.cross_group_retry && (
|
||||||
@@ -223,7 +220,15 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <GroupBadge group={group} ratio={ratio} />
|
return (
|
||||||
|
<TruncatedCell
|
||||||
|
className='-ml-1.5'
|
||||||
|
tooltipContent={group || '-'}
|
||||||
|
tooltipClassName='break-all'
|
||||||
|
>
|
||||||
|
<GroupBadge group={group} ratio={ratio} />
|
||||||
|
</TruncatedCell>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
size: 160,
|
size: 160,
|
||||||
meta: { mobileHidden: true },
|
meta: { mobileHidden: true },
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export function useDeploymentsColumns(opts: {
|
|||||||
if (!hardware)
|
if (!hardware)
|
||||||
return <span className='text-muted-foreground text-xs'>-</span>
|
return <span className='text-muted-foreground text-xs'>-</span>
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-wrap items-center gap-2'>
|
<div className='flex max-w-full min-w-0 flex-nowrap items-center gap-2 overflow-hidden'>
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={String(hardware)}
|
label={String(hardware)}
|
||||||
variant='neutral'
|
variant='neutral'
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { BadgeListCell } from '@/components/data-table'
|
import { BadgeCell, BadgeListCell } from '@/components/data-table'
|
||||||
import { GroupBadge } from '@/components/group-badge'
|
import { GroupBadge } from '@/components/group-badge'
|
||||||
import { ProviderBadge } from '@/components/provider-badge'
|
import { ProviderBadge } from '@/components/provider-badge'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
@@ -187,7 +187,9 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
|||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger render={<div className='-ml-1.5' />}>{badge}</TooltipTrigger>
|
<TooltipTrigger render={<div className='-ml-1.5' />}>
|
||||||
|
{badge}
|
||||||
|
</TooltipTrigger>
|
||||||
<TooltipContent
|
<TooltipContent
|
||||||
side='top'
|
side='top'
|
||||||
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||||
@@ -248,7 +250,11 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
|||||||
return <span className='text-muted-foreground text-xs'>-</span>
|
return <span className='text-muted-foreground text-xs'>-</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ProviderBadge iconKey={vendor.icon} label={vendor.name} />
|
return (
|
||||||
|
<BadgeCell>
|
||||||
|
<ProviderBadge iconKey={vendor.icon} label={vendor.name} />
|
||||||
|
</BadgeCell>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
filterFn: (row, id, value) => {
|
filterFn: (row, id, value) => {
|
||||||
if (!value || value.length === 0 || value.includes('all')) return true
|
if (!value || value.length === 0 || value.includes('all')) return true
|
||||||
|
|||||||
+14
-10
@@ -19,7 +19,11 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
import { type ColumnDef } from '@tanstack/react-table'
|
import { type ColumnDef } from '@tanstack/react-table'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||||
import { DataTableColumnHeader, BadgeListCell } from '@/components/data-table'
|
import {
|
||||||
|
BadgeCell,
|
||||||
|
BadgeListCell,
|
||||||
|
DataTableColumnHeader,
|
||||||
|
} from '@/components/data-table'
|
||||||
import { GroupBadge } from '@/components/group-badge'
|
import { GroupBadge } from '@/components/group-badge'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
|
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
|
||||||
@@ -74,7 +78,7 @@ export function usePricingColumns(
|
|||||||
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 14) : null
|
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 14) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex min-w-[200px] items-center gap-2'>
|
<div className='flex max-w-full min-w-0 items-center gap-2'>
|
||||||
{modelIcon}
|
{modelIcon}
|
||||||
<span className='truncate font-mono text-sm font-medium'>
|
<span className='truncate font-mono text-sm font-medium'>
|
||||||
{model.model_name}
|
{model.model_name}
|
||||||
@@ -124,7 +128,7 @@ export function usePricingColumns(
|
|||||||
if (dynamicSummary) {
|
if (dynamicSummary) {
|
||||||
if (dynamicSummary.isSpecialExpression) {
|
if (dynamicSummary.isSpecialExpression) {
|
||||||
return (
|
return (
|
||||||
<div className='max-w-[320px] min-w-[200px]'>
|
<div className='max-w-full min-w-0'>
|
||||||
<div className='text-xs font-medium text-amber-700 dark:text-amber-300'>
|
<div className='text-xs font-medium text-amber-700 dark:text-amber-300'>
|
||||||
{t('Special billing expression')}
|
{t('Special billing expression')}
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +152,7 @@ export function usePricingColumns(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='min-w-[180px]'>
|
<div className='max-w-full min-w-0'>
|
||||||
<span className='font-mono text-sm tabular-nums'>
|
<span className='font-mono text-sm tabular-nums'>
|
||||||
{primaryEntries.map((entry, index) => (
|
{primaryEntries.map((entry, index) => (
|
||||||
<span key={entry.key}>
|
<span key={entry.key}>
|
||||||
@@ -195,7 +199,7 @@ export function usePricingColumns(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='min-w-[160px]'>
|
<div className='max-w-full min-w-0'>
|
||||||
<span className='font-mono text-sm tabular-nums'>
|
<span className='font-mono text-sm tabular-nums'>
|
||||||
{inputPrice}
|
{inputPrice}
|
||||||
<span className='text-muted-foreground/40 mx-1'>/</span>
|
<span className='text-muted-foreground/40 mx-1'>/</span>
|
||||||
@@ -218,7 +222,7 @@ export function usePricingColumns(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='min-w-[100px]'>
|
<div className='max-w-full min-w-0'>
|
||||||
<span className='font-mono text-sm tabular-nums'>{price}</span>
|
<span className='font-mono text-sm tabular-nums'>{price}</span>
|
||||||
<div className='text-muted-foreground/50 text-[10px]'>
|
<div className='text-muted-foreground/50 text-[10px]'>
|
||||||
/ {t('request')}
|
/ {t('request')}
|
||||||
@@ -261,7 +265,7 @@ export function usePricingColumns(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='min-w-[80px]'>
|
<div className='max-w-full min-w-0'>
|
||||||
<span className='font-mono text-sm tabular-nums'>
|
<span className='font-mono text-sm tabular-nums'>
|
||||||
{stripTrailingZeros(cacheEntry.formatted)}
|
{stripTrailingZeros(cacheEntry.formatted)}
|
||||||
</span>
|
</span>
|
||||||
@@ -290,7 +294,7 @@ export function usePricingColumns(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='min-w-[80px]'>
|
<div className='max-w-full min-w-0'>
|
||||||
<span className='font-mono text-sm tabular-nums'>
|
<span className='font-mono text-sm tabular-nums'>
|
||||||
{cachedPrice}
|
{cachedPrice}
|
||||||
</span>
|
</span>
|
||||||
@@ -317,7 +321,7 @@ export function usePricingColumns(
|
|||||||
? getLobeIcon(model.vendor_icon, 12)
|
? getLobeIcon(model.vendor_icon, 12)
|
||||||
: null
|
: null
|
||||||
return (
|
return (
|
||||||
<span className='flex items-center gap-1.5'>
|
<BadgeCell className='gap-1.5'>
|
||||||
{vendorIcon}
|
{vendorIcon}
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={model.vendor_name}
|
label={model.vendor_name}
|
||||||
@@ -325,7 +329,7 @@ export function usePricingColumns(
|
|||||||
size='sm'
|
size='sm'
|
||||||
copyable={false}
|
copyable={false}
|
||||||
/>
|
/>
|
||||||
</span>
|
</BadgeCell>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
size: 130,
|
size: 130,
|
||||||
|
|||||||
+3
-7
@@ -74,13 +74,9 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
|||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: t('Name'),
|
header: t('Name'),
|
||||||
meta: { mobileTitle: true },
|
meta: { mobileTitle: true },
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
return (
|
<span className='font-medium'>{row.getValue('name')}</span>
|
||||||
<div className='max-w-[150px] truncate font-medium'>
|
),
|
||||||
{row.getValue('name')}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
size: 180,
|
size: 180,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+9
-4
@@ -20,6 +20,7 @@ import { useMemo } from 'react'
|
|||||||
import { type ColumnDef } from '@tanstack/react-table'
|
import { type ColumnDef } from '@tanstack/react-table'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { formatQuota } from '@/lib/format'
|
import { formatQuota } from '@/lib/format'
|
||||||
|
import { BadgeCell } from '@/components/data-table'
|
||||||
import { GroupBadge } from '@/components/group-badge'
|
import { GroupBadge } from '@/components/group-badge'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { TableId } from '@/components/table-id'
|
import { TableId } from '@/components/table-id'
|
||||||
@@ -48,7 +49,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const plan = row.original.plan
|
const plan = row.original.plan
|
||||||
return (
|
return (
|
||||||
<div className='max-w-[200px]'>
|
<div className='max-w-full min-w-0'>
|
||||||
<div className='truncate font-medium'>{plan.title}</div>
|
<div className='truncate font-medium'>{plan.title}</div>
|
||||||
{plan.subtitle && (
|
{plan.subtitle && (
|
||||||
<div className='text-muted-foreground truncate text-xs'>
|
<div className='text-muted-foreground truncate text-xs'>
|
||||||
@@ -134,7 +135,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const plan = row.original.plan
|
const plan = row.original.plan
|
||||||
return (
|
return (
|
||||||
<div className='flex gap-1'>
|
<BadgeCell>
|
||||||
{plan.stripe_price_id && (
|
{plan.stripe_price_id && (
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
label='Stripe'
|
label='Stripe'
|
||||||
@@ -152,7 +153,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
|||||||
copyable={false}
|
copyable={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</BadgeCell>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
size: 140,
|
size: 140,
|
||||||
@@ -182,7 +183,11 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
|||||||
<span className='text-muted-foreground'>{t('No Upgrade')}</span>
|
<span className='text-muted-foreground'>{t('No Upgrade')}</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <GroupBadge group={group} />
|
return (
|
||||||
|
<BadgeCell>
|
||||||
|
<GroupBadge group={group} />
|
||||||
|
</BadgeCell>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
size: 120,
|
size: 120,
|
||||||
},
|
},
|
||||||
|
|||||||
+17
-12
@@ -21,7 +21,7 @@ import { Pencil, Trash2, Plus } from 'lucide-react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { StaticDataTable } from '@/components/data-table'
|
import { BadgeCell, StaticDataTable } from '@/components/data-table'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { useDeleteProvider } from '../hooks/use-custom-oauth-mutations'
|
import { useDeleteProvider } from '../hooks/use-custom-oauth-mutations'
|
||||||
import type { CustomOAuthProvider } from '../types'
|
import type { CustomOAuthProvider } from '../types'
|
||||||
@@ -83,28 +83,33 @@ export function ProviderTable(props: ProviderTableProps) {
|
|||||||
id: 'slug',
|
id: 'slug',
|
||||||
header: t('Slug'),
|
header: t('Slug'),
|
||||||
cell: (provider) => (
|
cell: (provider) => (
|
||||||
<StatusBadge
|
<BadgeCell>
|
||||||
label={provider.slug}
|
<StatusBadge
|
||||||
variant='neutral'
|
label={provider.slug}
|
||||||
copyable={false}
|
variant='neutral'
|
||||||
/>
|
copyable={false}
|
||||||
|
/>
|
||||||
|
</BadgeCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
header: t('Status'),
|
header: t('Status'),
|
||||||
cell: (provider) => (
|
cell: (provider) => (
|
||||||
<StatusBadge
|
<BadgeCell>
|
||||||
label={provider.enabled ? t('Enabled') : t('Disabled')}
|
<StatusBadge
|
||||||
variant={provider.enabled ? 'success' : 'neutral'}
|
label={provider.enabled ? t('Enabled') : t('Disabled')}
|
||||||
copyable={false}
|
variant={provider.enabled ? 'success' : 'neutral'}
|
||||||
/>
|
copyable={false}
|
||||||
|
/>
|
||||||
|
</BadgeCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'client-id',
|
id: 'client-id',
|
||||||
header: t('Client ID'),
|
header: t('Client ID'),
|
||||||
cellClassName: 'text-muted-foreground max-w-[120px] truncate font-mono',
|
cellClassName:
|
||||||
|
'text-muted-foreground max-w-[120px] truncate font-mono',
|
||||||
cell: (provider) => provider.client_id,
|
cell: (provider) => provider.client_id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 { useEffect, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
@@ -54,7 +54,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { StaticDataTable } from '@/components/data-table'
|
import { BadgeCell, StaticDataTable } from '@/components/data-table'
|
||||||
import { Dialog } from '@/components/dialog'
|
import { Dialog } from '@/components/dialog'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||||
@@ -103,18 +103,37 @@ const colorOptions = [
|
|||||||
{ value: 'slate', label: 'Slate' },
|
{ value: 'slate', label: 'Slate' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function parseApiInfoList(data: string): ApiInfo[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data || '[]')
|
||||||
|
if (!Array.isArray(parsed)) return []
|
||||||
|
|
||||||
|
return parsed.map((item, idx) => ({
|
||||||
|
...item,
|
||||||
|
id: item.id || idx + 1,
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const updateOption = useUpdateOption()
|
const updateOption = useUpdateOption()
|
||||||
const apiInfoSchema = createApiInfoSchema(t)
|
const apiInfoSchema = createApiInfoSchema(t)
|
||||||
const [apiInfoList, setApiInfoList] = useState<ApiInfo[]>([])
|
const parsedApiInfoList = useMemo(() => parseApiInfoList(data), [data])
|
||||||
const [isEnabled, setIsEnabled] = useState(enabled)
|
const [draftApiInfoList, setDraftApiInfoList] = useState<ApiInfo[] | null>(
|
||||||
const [hasChanges, setHasChanges] = useState(false)
|
null
|
||||||
|
)
|
||||||
|
const [isEnabledDraft, setIsEnabledDraft] = useState<boolean | null>(null)
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
||||||
const [showDialog, setShowDialog] = useState(false)
|
const [showDialog, setShowDialog] = useState(false)
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
const [editingApiInfo, setEditingApiInfo] = useState<ApiInfo | null>(null)
|
const [editingApiInfo, setEditingApiInfo] = useState<ApiInfo | null>(null)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<'single' | 'batch'>('single')
|
const [deleteTarget, setDeleteTarget] = useState<'single' | 'batch'>('single')
|
||||||
|
const apiInfoList = draftApiInfoList ?? parsedApiInfoList
|
||||||
|
const isEnabled = isEnabledDraft ?? enabled
|
||||||
|
const hasChanges = draftApiInfoList !== null
|
||||||
|
|
||||||
const form = useForm<ApiInfoFormValues>({
|
const form = useForm<ApiInfoFormValues>({
|
||||||
resolver: zodResolver(apiInfoSchema),
|
resolver: zodResolver(apiInfoSchema),
|
||||||
@@ -126,33 +145,13 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data || '[]')
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
setApiInfoList(
|
|
||||||
parsed.map((item, idx) => ({
|
|
||||||
...item,
|
|
||||||
id: item.id || idx + 1,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setApiInfoList([])
|
|
||||||
}
|
|
||||||
}, [data])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsEnabled(enabled)
|
|
||||||
}, [enabled])
|
|
||||||
|
|
||||||
const handleToggleEnabled = async (checked: boolean) => {
|
const handleToggleEnabled = async (checked: boolean) => {
|
||||||
try {
|
try {
|
||||||
await updateOption.mutateAsync({
|
await updateOption.mutateAsync({
|
||||||
key: 'console_setting.api_info_enabled',
|
key: 'console_setting.api_info_enabled',
|
||||||
value: checked,
|
value: checked,
|
||||||
})
|
})
|
||||||
setIsEnabled(checked)
|
setIsEnabledDraft(checked)
|
||||||
toast.success(t('Setting saved'))
|
toast.success(t('Setting saved'))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('Failed to update setting'))
|
toast.error(t('Failed to update setting'))
|
||||||
@@ -198,17 +197,15 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
|||||||
|
|
||||||
const confirmDelete = () => {
|
const confirmDelete = () => {
|
||||||
if (deleteTarget === 'single' && editingApiInfo) {
|
if (deleteTarget === 'single' && editingApiInfo) {
|
||||||
setApiInfoList((prev) =>
|
setDraftApiInfoList(
|
||||||
prev.filter((item) => item.id !== editingApiInfo.id)
|
apiInfoList.filter((item) => item.id !== editingApiInfo.id)
|
||||||
)
|
)
|
||||||
setHasChanges(true)
|
|
||||||
toast.success(t('API info deleted. Click "Save Settings" to apply.'))
|
toast.success(t('API info deleted. Click "Save Settings" to apply.'))
|
||||||
} else if (deleteTarget === 'batch') {
|
} else if (deleteTarget === 'batch') {
|
||||||
setApiInfoList((prev) =>
|
setDraftApiInfoList(
|
||||||
prev.filter((item) => !selectedIds.includes(item.id))
|
apiInfoList.filter((item) => !selectedIds.includes(item.id))
|
||||||
)
|
)
|
||||||
setSelectedIds([])
|
setSelectedIds([])
|
||||||
setHasChanges(true)
|
|
||||||
toast.success(
|
toast.success(
|
||||||
t('{{count}} API entries deleted. Click "Save Settings" to apply.', {
|
t('{{count}} API entries deleted. Click "Save Settings" to apply.', {
|
||||||
count: selectedIds.length,
|
count: selectedIds.length,
|
||||||
@@ -221,18 +218,17 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
|||||||
|
|
||||||
const handleSubmitForm = (values: ApiInfoFormValues) => {
|
const handleSubmitForm = (values: ApiInfoFormValues) => {
|
||||||
if (editingApiInfo) {
|
if (editingApiInfo) {
|
||||||
setApiInfoList((prev) =>
|
setDraftApiInfoList(
|
||||||
prev.map((item) =>
|
apiInfoList.map((item) =>
|
||||||
item.id === editingApiInfo.id ? { ...item, ...values } : item
|
item.id === editingApiInfo.id ? { ...item, ...values } : item
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
toast.success(t('API info updated. Click "Save Settings" to apply.'))
|
toast.success(t('API info updated. Click "Save Settings" to apply.'))
|
||||||
} else {
|
} else {
|
||||||
const newId = Math.max(...apiInfoList.map((item) => item.id), 0) + 1
|
const newId = Math.max(...apiInfoList.map((item) => item.id), 0) + 1
|
||||||
setApiInfoList((prev) => [...prev, { id: newId, ...values }])
|
setDraftApiInfoList([...apiInfoList, { id: newId, ...values }])
|
||||||
toast.success(t('API info added. Click "Save Settings" to apply.'))
|
toast.success(t('API info added. Click "Save Settings" to apply.'))
|
||||||
}
|
}
|
||||||
setHasChanges(true)
|
|
||||||
setShowDialog(false)
|
setShowDialog(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +239,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
|||||||
value: JSON.stringify(apiInfoList),
|
value: JSON.stringify(apiInfoList),
|
||||||
})
|
})
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setHasChanges(false)
|
setDraftApiInfoList(null)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('Failed to save API info'))
|
toast.error(t('Failed to save API info'))
|
||||||
@@ -330,22 +326,26 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
|||||||
header: t('URL'),
|
header: t('URL'),
|
||||||
cellClassName: 'max-w-xs truncate font-mono text-sm',
|
cellClassName: 'max-w-xs truncate font-mono text-sm',
|
||||||
cell: (apiInfo) => (
|
cell: (apiInfo) => (
|
||||||
<StatusBadge
|
<BadgeCell>
|
||||||
label={apiInfo.url}
|
<StatusBadge
|
||||||
variant='neutral'
|
label={apiInfo.url}
|
||||||
copyable={false}
|
variant='neutral'
|
||||||
/>
|
copyable={false}
|
||||||
|
/>
|
||||||
|
</BadgeCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'route',
|
id: 'route',
|
||||||
header: t('Route'),
|
header: t('Route'),
|
||||||
cell: (apiInfo) => (
|
cell: (apiInfo) => (
|
||||||
<StatusBadge
|
<BadgeCell>
|
||||||
label={apiInfo.route}
|
<StatusBadge
|
||||||
variant='neutral'
|
label={apiInfo.route}
|
||||||
copyable={false}
|
variant='neutral'
|
||||||
/>
|
copyable={false}
|
||||||
|
/>
|
||||||
|
</BadgeCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+7
-9
@@ -27,6 +27,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
|
import { BadgeCell } from '@/components/data-table'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import type { RatioType } from '../types'
|
import type { RatioType } from '../types'
|
||||||
import {
|
import {
|
||||||
@@ -63,8 +64,8 @@ export function useUpstreamRatioSyncColumns(
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const model = row.original.model
|
const model = row.original.model
|
||||||
return (
|
return (
|
||||||
<div className='flex min-w-[180px] items-center gap-2'>
|
<div className='flex max-w-full min-w-0 items-center gap-2'>
|
||||||
<span className='font-medium'>{model}</span>
|
<span className='truncate font-medium'>{model}</span>
|
||||||
{row.original.billingConflict && (
|
{row.original.billingConflict && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -94,14 +95,11 @@ export function useUpstreamRatioSyncColumns(
|
|||||||
ratioTypeFilter
|
ratioTypeFilter
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<div className='flex min-w-[260px] flex-col gap-2'>
|
<div className='flex max-w-full min-w-0 flex-col gap-2'>
|
||||||
{fields.map((ratioType) => {
|
{fields.map((ratioType) => {
|
||||||
const current = row.original.ratioTypes[ratioType]?.current
|
const current = row.original.ratioTypes[ratioType]?.current
|
||||||
return (
|
return (
|
||||||
<div
|
<BadgeCell key={ratioType} className='ml-0 flex-wrap gap-2'>
|
||||||
key={ratioType}
|
|
||||||
className='flex min-w-0 flex-wrap items-center gap-2'
|
|
||||||
>
|
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={getSyncFieldLabel(ratioType, t)}
|
label={getSyncFieldLabel(ratioType, t)}
|
||||||
autoColor={ratioType}
|
autoColor={ratioType}
|
||||||
@@ -136,7 +134,7 @@ export function useUpstreamRatioSyncColumns(
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
</div>
|
</BadgeCell>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -215,7 +213,7 @@ export function useUpstreamRatioSyncColumns(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex min-w-[280px] flex-col gap-2'>
|
<div className='flex max-w-full min-w-0 flex-col gap-2'>
|
||||||
{fields.map((ratioType) => {
|
{fields.map((ratioType) => {
|
||||||
const diff = row.original.ratioTypes[ratioType]
|
const diff = row.original.ratioTypes[ratioType]
|
||||||
const upstreamVal = diff?.upstreams?.[upstreamName]
|
const upstreamVal = diff?.upstreams?.[upstreamName]
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
|
import { BadgeCell } from '@/components/data-table'
|
||||||
import { GroupBadge } from '@/components/group-badge'
|
import { GroupBadge } from '@/components/group-badge'
|
||||||
import { LongText } from '@/components/long-text'
|
import { LongText } from '@/components/long-text'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
@@ -227,7 +228,11 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
|||||||
header: t('Group'),
|
header: t('Group'),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const group = row.getValue('group') as string
|
const group = row.getValue('group') as string
|
||||||
return <GroupBadge group={group} />
|
return (
|
||||||
|
<BadgeCell>
|
||||||
|
<GroupBadge group={group} />
|
||||||
|
</BadgeCell>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
filterFn: (row, id, value) => {
|
filterFn: (row, id, value) => {
|
||||||
const group = String(row.getValue(id) || t('User Group')).toLowerCase()
|
const group = String(row.getValue(id) || t('User Group')).toLowerCase()
|
||||||
@@ -272,7 +277,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
|||||||
const inviterId = user.inviter_id || 0
|
const inviterId = user.inviter_id || 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex min-w-[220px] flex-wrap items-center gap-1'>
|
<div className='flex max-w-full min-w-0 flex-wrap items-center gap-1 overflow-hidden'>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
render={
|
||||||
|
|||||||
Reference in New Issue
Block a user