✨ feat(web): polish themed data views and add task log details
This commit is contained in:
Vendored
-3
@@ -102,7 +102,6 @@
|
||||
"marked": "^18.0.5",
|
||||
"motion": "^12.42.2",
|
||||
"nanoid": "^5.1.16",
|
||||
"next-themes": "^0.4.6",
|
||||
"qrcode.react": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-day-picker": "^10.0.1",
|
||||
@@ -2243,8 +2242,6 @@
|
||||
|
||||
"newapi-web": ["newapi-web@workspace:default"],
|
||||
|
||||
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
Vendored
+16
@@ -1021,6 +1021,22 @@ html.dark .with-pastel-balls::before {
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
/* Keep badge text complete and on one line. */
|
||||
.semi-tag {
|
||||
flex-shrink: 0;
|
||||
min-width: max-content;
|
||||
max-width: none;
|
||||
overflow: visible;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.semi-tag-content {
|
||||
max-width: none;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ==================== 使用日志: channel affinity tag ==================== */
|
||||
.semi-tag.channel-affinity-tag {
|
||||
border: 1px solid rgba(var(--semi-cyan-5), 0.35);
|
||||
|
||||
Vendored
+1
-1
@@ -18,7 +18,7 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "inverted",
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {
|
||||
"@ai-elements": "https://registry.ai-sdk.dev/{name}.json"
|
||||
|
||||
Vendored
-1
@@ -53,7 +53,6 @@
|
||||
"marked": "^18.0.5",
|
||||
"motion": "^12.42.2",
|
||||
"nanoid": "^5.1.16",
|
||||
"next-themes": "^0.4.6",
|
||||
"qrcode.react": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-day-picker": "^10.0.1",
|
||||
|
||||
@@ -32,32 +32,28 @@ import type {
|
||||
const alertConfig = {
|
||||
note: {
|
||||
label: 'Note',
|
||||
className:
|
||||
'border-blue-500/40 bg-blue-500/8 text-blue-950 dark:text-blue-100',
|
||||
markerClassName: 'text-blue-600 dark:text-blue-300',
|
||||
className: 'border-info/25 bg-info/8 text-foreground',
|
||||
markerClassName: 'text-status-info',
|
||||
},
|
||||
tip: {
|
||||
label: 'Tip',
|
||||
className:
|
||||
'border-emerald-500/40 bg-emerald-500/8 text-emerald-950 dark:text-emerald-100',
|
||||
markerClassName: 'text-emerald-600 dark:text-emerald-300',
|
||||
className: 'border-success/25 bg-success/8 text-foreground',
|
||||
markerClassName: 'text-status-success',
|
||||
},
|
||||
important: {
|
||||
label: 'Important',
|
||||
className:
|
||||
'border-violet-500/40 bg-violet-500/8 text-violet-950 dark:text-violet-100',
|
||||
markerClassName: 'text-violet-600 dark:text-violet-300',
|
||||
className: 'border-border bg-muted/40 text-foreground',
|
||||
markerClassName: 'text-foreground',
|
||||
},
|
||||
warning: {
|
||||
label: 'Warning',
|
||||
className:
|
||||
'border-amber-500/40 bg-amber-500/8 text-amber-950 dark:text-amber-100',
|
||||
markerClassName: 'text-amber-600 dark:text-amber-300',
|
||||
className: 'border-warning/30 bg-warning/8 text-foreground',
|
||||
markerClassName: 'text-status-warning',
|
||||
},
|
||||
caution: {
|
||||
label: 'Caution',
|
||||
className: 'border-red-500/40 bg-red-500/8 text-red-950 dark:text-red-100',
|
||||
markerClassName: 'text-red-600 dark:text-red-300',
|
||||
className: 'border-destructive/25 bg-destructive/8 text-foreground',
|
||||
markerClassName: 'text-status-destructive',
|
||||
},
|
||||
} satisfies Record<AlertKind, AlertConfig>
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export function BadgeCell({ className, ...props }: BadgeCellProps) {
|
||||
<div
|
||||
data-slot='badge-cell'
|
||||
className={cn(
|
||||
'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',
|
||||
'flex max-w-full min-w-0 items-center gap-1 overflow-visible',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -60,8 +60,8 @@ function getPinnedColumnClassName(
|
||||
) {
|
||||
const edgeClassName =
|
||||
pinnedColumn.side === 'left'
|
||||
? 'shadow-[8px_0_10px_-10px_hsl(var(--foreground))]'
|
||||
: 'shadow-[-8px_0_10px_-10px_hsl(var(--foreground))]'
|
||||
? 'shadow-[8px_0_10px_-10px_var(--foreground)]'
|
||||
: 'shadow-[-8px_0_10px_-10px_var(--foreground)]'
|
||||
|
||||
return cn(
|
||||
'sticky whitespace-nowrap',
|
||||
|
||||
@@ -58,9 +58,9 @@ function DataTableRowInner<TData>({
|
||||
className={cn(
|
||||
'max-w-full min-w-0',
|
||||
contentMode === 'full' &&
|
||||
'max-w-none overflow-visible [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_[data-slot=status-badge]]:max-w-none [&_[data-slot=status-badge]]:overflow-visible [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip',
|
||||
'max-w-none overflow-visible [&_.truncate]:overflow-visible [&_.truncate]:text-clip',
|
||||
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',
|
||||
'whitespace-normal break-words [overflow-wrap:anywhere] [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal',
|
||||
contentMode === 'summary' &&
|
||||
'whitespace-normal break-words [overflow-wrap:anywhere]',
|
||||
getColumnClassName?.(cell.column.id, 'cell')
|
||||
|
||||
@@ -23,7 +23,7 @@ import { cn } from '@/lib/utils'
|
||||
export type DataTableContentMode = 'full' | 'wrap' | 'summary'
|
||||
|
||||
const VALUE_WRAP_CLASS =
|
||||
'whitespace-normal break-words [overflow-wrap:anywhere] [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal [&_.whitespace-nowrap]:whitespace-normal [&_[data-slot=status-badge]]:h-auto [&_[data-slot=status-badge]]:overflow-visible [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
|
||||
'whitespace-normal break-words [overflow-wrap:anywhere] [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal'
|
||||
|
||||
interface DataTableCardFieldProps {
|
||||
children: ReactNode
|
||||
|
||||
@@ -77,12 +77,12 @@ export function CardRowContent<TData>(props: {
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col'>
|
||||
{props.compact && (titleCell || badgeCell) && (
|
||||
<div className='flex min-w-0 items-start justify-between gap-3'>
|
||||
<div className='min-w-0 flex-1 text-[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'>
|
||||
<div className='flex min-w-0 flex-wrap items-start justify-between gap-3'>
|
||||
<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'>
|
||||
{titleCell ? renderCellContent(titleCell) : null}
|
||||
</div>
|
||||
{badgeCell && (
|
||||
<div className='max-w-1/2 shrink text-right'>
|
||||
<div className='shrink-0 text-right'>
|
||||
{renderCellContent(badgeCell)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -19,12 +19,12 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { Grid2X2, Table2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/design-system/tabs'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
DATA_TABLE_VIEW_MODES,
|
||||
@@ -37,12 +37,6 @@ export type DataTableViewModeToggleProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
type Segment = {
|
||||
value: DataTableViewMode
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
tooltip: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable icon segmented control for switching a data table between table and
|
||||
* card views. Shared, accessible version of the local control used by the
|
||||
@@ -51,56 +45,42 @@ type Segment = {
|
||||
export function DataTableViewModeToggle(props: DataTableViewModeToggleProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const segments: Segment[] = [
|
||||
{
|
||||
value: DATA_TABLE_VIEW_MODES.CARD,
|
||||
icon: Grid2X2,
|
||||
tooltip: t('Card view'),
|
||||
},
|
||||
{
|
||||
value: DATA_TABLE_VIEW_MODES.TABLE,
|
||||
icon: Table2,
|
||||
tooltip: t('Table view'),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
role='group'
|
||||
aria-label={t('View mode')}
|
||||
className={cn(
|
||||
'bg-muted/60 inline-flex h-7 items-center rounded-lg border p-0.5 sm:h-8',
|
||||
props.className
|
||||
)}
|
||||
<Tabs
|
||||
value={props.value}
|
||||
onValueChange={(value) => props.onChange(value as DataTableViewMode)}
|
||||
className={props.className}
|
||||
>
|
||||
{segments.map((segment) => {
|
||||
const Icon = segment.icon
|
||||
const isActive = segment.value === props.value
|
||||
return (
|
||||
<Tooltip key={segment.value}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => props.onChange(segment.value)}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'inline-flex h-full w-7 items-center justify-center rounded-md text-xs font-medium transition-all',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className='size-3.5' />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side='bottom' className='text-xs'>
|
||||
{segment.tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<TabsList aria-label={t('View mode')}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<TabsTrigger
|
||||
value={DATA_TABLE_VIEW_MODES.TABLE}
|
||||
className='px-2'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Table2 aria-hidden='true' className='size-3.5' />
|
||||
<span className='sr-only'>{t('Table view')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>{t('Table view')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<TabsTrigger
|
||||
value={DATA_TABLE_VIEW_MODES.CARD}
|
||||
className='px-2'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Grid2X2 aria-hidden='true' className='size-3.5' />
|
||||
<span className='sr-only'>{t('Card view')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>{t('Card view')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
+3
-2
@@ -23,8 +23,9 @@ import { cn } from '@/lib/utils'
|
||||
export const sideDrawerContentClassName = (className?: string) =>
|
||||
cn(
|
||||
// Width: pass `sm:max-w-*` (or `sm:max-w-none`) in className. SheetContent
|
||||
// defaults to `sm:max-w-sm` for left/right; plain utilities merge correctly.
|
||||
'bg-background text-foreground flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 shadow-none',
|
||||
// defaults to 75% width on mobile and `sm:max-w-sm` for left/right.
|
||||
// Match its side variants so product drawers occupy the full mobile viewport.
|
||||
'bg-background text-foreground flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 shadow-none data-[side=left]:w-full data-[side=right]:w-full',
|
||||
className
|
||||
)
|
||||
|
||||
|
||||
+11
-3
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getIdentityTextColorClass } from '@/lib/colors'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { StatusBadge, type StatusBadgeProps } from './status-badge'
|
||||
@@ -55,6 +56,7 @@ export function GroupBadge(props: GroupBadgeProps) {
|
||||
const groupName = group?.trim()
|
||||
const isAutoGroup = groupName === 'auto'
|
||||
const isEmptyGroup = !groupName
|
||||
const colorKey = groupName || labelOverride || 'group'
|
||||
const label = getGroupLabel({
|
||||
labelOverride,
|
||||
groupName,
|
||||
@@ -67,7 +69,12 @@ export function GroupBadge(props: GroupBadgeProps) {
|
||||
<StatusBadge
|
||||
{...badgeProps}
|
||||
variant='neutral'
|
||||
className={cn('min-w-0 shrink overflow-hidden', className)}
|
||||
appearance='plain'
|
||||
className={cn(
|
||||
'shrink-0 overflow-visible',
|
||||
getIdentityTextColorClass(colorKey),
|
||||
className
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</StatusBadge>
|
||||
@@ -78,10 +85,11 @@ export function GroupBadge(props: GroupBadgeProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<span className='inline-flex max-w-full min-w-0 items-center gap-2 text-xs'>
|
||||
<span className='max-w-full min-w-0 overflow-hidden'>{badge}</span>
|
||||
<span className='inline-flex w-max shrink-0 items-center gap-2 text-xs whitespace-nowrap'>
|
||||
<span className='inline-flex shrink-0'>{badge}</span>
|
||||
<StatusBadge
|
||||
variant={getGroupRatioVariant(ratio)}
|
||||
appearance='plain'
|
||||
className='shrink-0 tabular-nums'
|
||||
>
|
||||
{ratio}x
|
||||
|
||||
+2
-2
@@ -50,13 +50,13 @@ export function Glow({ className, variant, ...props }: GlowProps) {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-1/2 h-[256px] w-[60%] -translate-x-1/2 scale-[2.5] rounded-[50%] bg-radial from-amber-500/60 from-10% to-amber-500/0 to-60% opacity-40 sm:h-[512px] dark:opacity-80',
|
||||
'from-primary/60 to-primary/0 absolute left-1/2 h-[256px] w-[60%] -translate-x-1/2 scale-[2.5] rounded-[50%] bg-radial from-10% to-60% opacity-40 sm:h-[512px]',
|
||||
variant === 'center' && '-translate-y-1/2'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-1/2 h-[128px] w-[40%] -translate-x-1/2 scale-200 rounded-[50%] bg-radial from-yellow-400/50 from-10% to-yellow-400/0 to-60% opacity-30 sm:h-[256px] dark:opacity-70',
|
||||
'from-primary/40 to-primary/0 absolute left-1/2 h-[128px] w-[40%] -translate-x-1/2 scale-200 rounded-[50%] bg-radial from-10% to-60% opacity-30 sm:h-[256px]',
|
||||
variant === 'center' && '-translate-y-1/2'
|
||||
)}
|
||||
/>
|
||||
|
||||
+1
-8
@@ -32,12 +32,5 @@ export function NavigationProgress() {
|
||||
}
|
||||
}, [state.status])
|
||||
|
||||
return (
|
||||
<LoadingBar
|
||||
color='var(--muted-foreground)'
|
||||
ref={ref}
|
||||
shadow={true}
|
||||
height={2}
|
||||
/>
|
||||
)
|
||||
return <LoadingBar color='var(--primary)' ref={ref} shadow height={2} />
|
||||
}
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ export function ProviderBadge({
|
||||
<StatusBadge
|
||||
{...badgeProps}
|
||||
variant='neutral'
|
||||
className={cn('min-w-0 shrink overflow-hidden', className)}
|
||||
className={cn('shrink-0 overflow-visible', className)}
|
||||
>
|
||||
{icon && (
|
||||
<span
|
||||
|
||||
+12
-5
@@ -35,8 +35,11 @@ export type StatusVariant =
|
||||
|
||||
export type StatusBadgeAppearance = 'soft' | 'outline' | 'plain'
|
||||
|
||||
export const StatusBadgeAppearanceContext =
|
||||
React.createContext<StatusBadgeAppearance | null>(null)
|
||||
|
||||
export const statusBadgeVariants = cva(
|
||||
'focus-visible:ring-ring/50 inline-flex w-fit max-w-full min-w-0 shrink items-center justify-center overflow-hidden rounded-md text-ellipsis whitespace-nowrap font-medium tracking-normal outline-none transition-[color,background-color,border-color,filter] duration-150 focus-visible:ring-[3px] has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 [&_[data-icon]]:pointer-events-none [&_[data-icon]]:size-3 [&_[data-icon]]:shrink-0',
|
||||
'focus-visible:ring-ring/50 inline-flex w-fit max-w-none shrink-0 items-center justify-center overflow-visible rounded-md whitespace-nowrap font-medium tracking-normal outline-none transition-[color,background-color,border-color,filter] duration-150 focus-visible:ring-[3px] has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 [&_.truncate]:max-w-none [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-nowrap [&_[data-icon]]:pointer-events-none [&_[data-icon]]:size-3 [&_[data-icon]]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
@@ -110,7 +113,7 @@ export const statusBadgeVariants = cva(
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
appearance: 'soft',
|
||||
appearance: 'plain',
|
||||
size: 'sm',
|
||||
variant: 'neutral',
|
||||
},
|
||||
@@ -129,7 +132,8 @@ export function StatusBadge({
|
||||
variant: variantProp,
|
||||
...props
|
||||
}: StatusBadgeProps) {
|
||||
const appearance = appearanceProp ?? 'soft'
|
||||
const contextAppearance = React.useContext(StatusBadgeAppearanceContext)
|
||||
const appearance = appearanceProp ?? contextAppearance ?? 'plain'
|
||||
const size = sizeProp ?? 'sm'
|
||||
const variant = variantProp ?? 'neutral'
|
||||
const leadingIcons: React.ReactNode[] = []
|
||||
@@ -163,7 +167,10 @@ export function StatusBadge({
|
||||
<>
|
||||
{leadingIcons}
|
||||
{labelChildren.length > 0 && (
|
||||
<span data-slot='status-badge-label' className='min-w-0 truncate'>
|
||||
<span
|
||||
data-slot='status-badge-label'
|
||||
className='max-w-none shrink-0 overflow-visible whitespace-nowrap'
|
||||
>
|
||||
{labelChildren}
|
||||
</span>
|
||||
)}
|
||||
@@ -249,7 +256,7 @@ export function StatusBadgeList<T>(props: StatusBadgeListProps<T>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex max-w-full min-w-0 items-center gap-1 overflow-hidden',
|
||||
'flex max-w-full min-w-0 items-center gap-1 overflow-visible',
|
||||
className
|
||||
)}
|
||||
{...domProps}
|
||||
|
||||
+3
-3
@@ -32,15 +32,15 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
export function ThemeSwitch() {
|
||||
const { t } = useTranslation()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const { resolvedTheme, theme, setTheme } = useTheme()
|
||||
|
||||
/* Update theme-color meta tag
|
||||
* when theme is updated */
|
||||
useEffect(() => {
|
||||
const themeColor = theme === 'dark' ? '#020817' : '#fff'
|
||||
const themeColor = resolvedTheme === 'dark' ? '#2c2c2c' : '#fff'
|
||||
const metaThemeColor = document.querySelector("meta[name='theme-color']")
|
||||
if (metaThemeColor) metaThemeColor.setAttribute('content', themeColor)
|
||||
}, [theme])
|
||||
}, [resolvedTheme])
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
|
||||
+1
-1
@@ -140,7 +140,7 @@ function ComboboxContent({
|
||||
data-slot='combobox-content'
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
'dark group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-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',
|
||||
'group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+2
-2
@@ -72,7 +72,7 @@ function ContextMenuContent({
|
||||
<ContextMenuPrimitive.Popup
|
||||
data-slot='context-menu-content'
|
||||
className={cn(
|
||||
'dark z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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',
|
||||
'z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -171,7 +171,7 @@ function ContextMenuSubContent({
|
||||
return (
|
||||
<ContextMenuContent
|
||||
data-slot='context-menu-sub-content'
|
||||
className='dark shadow-lg'
|
||||
className='shadow-lg'
|
||||
side='right'
|
||||
{...props}
|
||||
/>
|
||||
|
||||
+2
-2
@@ -59,7 +59,7 @@ function DropdownMenuContent({
|
||||
<MenuPrimitive.Popup
|
||||
data-slot='dropdown-menu-content'
|
||||
className={cn(
|
||||
'dark z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95',
|
||||
'z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -160,7 +160,7 @@ function DropdownMenuSubContent({
|
||||
<DropdownMenuContent
|
||||
data-slot='dropdown-menu-sub-content'
|
||||
className={cn(
|
||||
'dark w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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',
|
||||
'w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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',
|
||||
className
|
||||
)}
|
||||
align={align}
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
|
||||
<kbd
|
||||
data-slot='kbd'
|
||||
className={cn(
|
||||
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
|
||||
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-muted in-data-[slot=tooltip-content]:text-muted-foreground [&_svg:not([class*='size-'])]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+2
-2
@@ -100,7 +100,7 @@ function MenubarContent({
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'dark min-w-36 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95',
|
||||
'min-w-36 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -275,7 +275,7 @@ function MenubarSubContent({
|
||||
<DropdownMenuSubContent
|
||||
data-slot='menubar-sub-content'
|
||||
className={cn(
|
||||
'dark min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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',
|
||||
'min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ function SelectContent({
|
||||
data-slot='select-content'
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn(
|
||||
'dark relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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',
|
||||
'relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+27
-3
@@ -26,15 +26,16 @@ import {
|
||||
Loading03Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Toaster as Sonner, type ToasterProps } from 'sonner'
|
||||
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = 'system' } = useTheme()
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
theme={resolvedTheme}
|
||||
className='toaster group'
|
||||
icons={{
|
||||
success: (
|
||||
@@ -78,6 +79,29 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
'--success-bg':
|
||||
'color-mix(in oklch, var(--success) 10%, var(--popover))',
|
||||
'--success-text':
|
||||
'color-mix(in oklch, var(--success) 55%, var(--foreground))',
|
||||
'--success-border':
|
||||
'color-mix(in oklch, var(--success) 32%, var(--border))',
|
||||
'--info-bg': 'color-mix(in oklch, var(--info) 10%, var(--popover))',
|
||||
'--info-text':
|
||||
'color-mix(in oklch, var(--info) 55%, var(--foreground))',
|
||||
'--info-border':
|
||||
'color-mix(in oklch, var(--info) 32%, var(--border))',
|
||||
'--warning-bg':
|
||||
'color-mix(in oklch, var(--warning) 10%, var(--popover))',
|
||||
'--warning-text':
|
||||
'color-mix(in oklch, var(--warning) 55%, var(--foreground))',
|
||||
'--warning-border':
|
||||
'color-mix(in oklch, var(--warning) 32%, var(--border))',
|
||||
'--error-bg':
|
||||
'color-mix(in oklch, var(--destructive) 10%, var(--popover))',
|
||||
'--error-text':
|
||||
'color-mix(in oklch, var(--destructive) 55%, var(--foreground))',
|
||||
'--error-border':
|
||||
'color-mix(in oklch, var(--destructive) 32%, var(--border))',
|
||||
'--border-radius': 'var(--radius)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
|
||||
+6
-4
@@ -21,7 +21,7 @@ import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
delay = 300,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
@@ -44,7 +44,7 @@ function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = 'top',
|
||||
sideOffset = 4,
|
||||
sideOffset = 8,
|
||||
align = 'center',
|
||||
alignOffset = 0,
|
||||
children,
|
||||
@@ -66,13 +66,15 @@ function TooltipContent({
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot='tooltip-content'
|
||||
className={cn(
|
||||
'z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 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',
|
||||
'bg-popover text-popover-foreground border-border z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs leading-snug shadow-md transition-opacity duration-100 has-data-[slot=kbd]:pr-1.5 data-[ending-style]:opacity-0 data-[instant]:transition-none data-[starting-style]:opacity-0 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className='bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5' />
|
||||
<TooltipPrimitive.Arrow className='z-50 block h-1.5 w-3 overflow-hidden data-[side=bottom]:-top-1.5 data-[side=inline-end]:-left-2 data-[side=inline-end]:-rotate-90 data-[side=inline-start]:-right-2 data-[side=inline-start]:rotate-90 data-[side=left]:-right-2 data-[side=left]:rotate-90 data-[side=right]:-left-2 data-[side=right]:-rotate-90 data-[side=top]:-bottom-1.5 data-[side=top]:rotate-180'>
|
||||
<span className='border-border bg-popover absolute bottom-0 left-1/2 size-2 -translate-x-1/2 translate-y-1/2 rotate-45 border' />
|
||||
</TooltipPrimitive.Arrow>
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
|
||||
+95
-115
@@ -17,22 +17,24 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { flexRender, type Row } from '@tanstack/react-table'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
BadgeListCellDisplayContext,
|
||||
DataTableCardField,
|
||||
DataTableCardRow,
|
||||
} from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { isTagAggregateRow } from '../lib'
|
||||
import { CHANNEL_STATUS } from '../constants'
|
||||
import { isTagAggregateRow, parseGroupsList } from '../lib'
|
||||
import type { Channel } from '../types'
|
||||
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
|
||||
import { useChannels } from './channels-provider'
|
||||
|
||||
const SENSITIVE_MASK = '••••'
|
||||
|
||||
/**
|
||||
* Bespoke channel card for the card view. Reuses every column's existing cell
|
||||
* renderer via `flexRender`, so the table's information and interactions are
|
||||
* preserved. All fields are always visible — no "More" disclosure.
|
||||
* renderer via `flexRender`, preserving the original compact two-column
|
||||
* hierarchy and all row interactions.
|
||||
*/
|
||||
function ChannelCardComponent({
|
||||
row,
|
||||
@@ -42,9 +44,9 @@ function ChannelCardComponent({
|
||||
isSelected: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { sensitiveVisible } = useChannels()
|
||||
const isTagRow = isTagAggregateRow(row.original)
|
||||
const cells = row.getVisibleCells()
|
||||
const visibleColumnIds = new Set(cells.map((cell) => cell.column.id))
|
||||
const cells = row.getAllCells()
|
||||
|
||||
const renderCell = (id: string) => {
|
||||
const cell = cells.find((candidate) => candidate.column.id === id)
|
||||
@@ -54,129 +56,107 @@ function ChannelCardComponent({
|
||||
return flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
}
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
balance: t('Used / Remaining'),
|
||||
response_time: t('Response'),
|
||||
test_time: t('Last Tested'),
|
||||
}
|
||||
const groups = parseGroupsList(row.original.group ?? '')
|
||||
|
||||
const selectCell = renderCell('select')
|
||||
const typeCell = renderCell('type')
|
||||
const idCell = renderCell('id')
|
||||
const nameCell = renderCell('name')
|
||||
const statusCell = renderCell('status')
|
||||
const actionsCell = renderCell('actions')
|
||||
const priorityCell = renderCell('priority')
|
||||
const weightCell = renderCell('weight')
|
||||
const balanceCell = renderCell('balance')
|
||||
const modelsCell = renderCell('models')
|
||||
const groupsCell = renderCell('group')
|
||||
const tagCell = renderCell('tag')
|
||||
const responseCell = renderCell('response_time')
|
||||
const testCell = renderCell('test_time')
|
||||
|
||||
const showId = !isTagRow && visibleColumnIds.has('id')
|
||||
const showTag = !isTagRow && visibleColumnIds.has('tag')
|
||||
const showModels = !isTagRow && visibleColumnIds.has('models')
|
||||
const showTestTime = !isTagRow && visibleColumnIds.has('test_time')
|
||||
const hasStatRows =
|
||||
showId ||
|
||||
showTag ||
|
||||
showTestTime ||
|
||||
visibleColumnIds.has('balance') ||
|
||||
visibleColumnIds.has('response_time') ||
|
||||
visibleColumnIds.has('priority') ||
|
||||
visibleColumnIds.has('weight')
|
||||
const hasBadgeSections = showModels || visibleColumnIds.has('group')
|
||||
const labelClass = 'text-muted-foreground text-xs font-medium select-none'
|
||||
const showStatusBadge =
|
||||
isTagRow ||
|
||||
(row.original.status !== CHANNEL_STATUS.ENABLED &&
|
||||
row.original.status !== CHANNEL_STATUS.MANUAL_DISABLED)
|
||||
|
||||
return (
|
||||
<ChannelRowActionsLayoutContext.Provider value='card'>
|
||||
<BadgeListCellDisplayContext.Provider value='full'>
|
||||
<div
|
||||
data-state={isSelected ? 'selected' : undefined}
|
||||
className='flex h-full min-w-0 flex-col'
|
||||
>
|
||||
<div className='flex min-w-0 items-start gap-2.5'>
|
||||
{!isTagRow && selectCell && (
|
||||
<span className='mt-0.5 shrink-0'>{selectCell}</span>
|
||||
<div
|
||||
data-state={isSelected ? 'selected' : undefined}
|
||||
className='flex flex-col gap-3'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
{!isTagRow && selectCell && (
|
||||
<span className='shrink-0'>{selectCell}</span>
|
||||
)}
|
||||
<div className='min-w-0'>{typeCell}</div>
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-1.5'>
|
||||
{showStatusBadge && statusCell}
|
||||
<ChannelRowActionsLayoutContext.Provider value='card'>
|
||||
{actionsCell}
|
||||
</ChannelRowActionsLayoutContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-3'>
|
||||
<div className='min-w-0 text-sm'>
|
||||
{!isTagRow && (
|
||||
<div className={labelClass}>
|
||||
#{sensitiveVisible ? row.original.id : SENSITIVE_MASK}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='min-w-0 flex-1'>
|
||||
{visibleColumnIds.has('name') && (
|
||||
<div className='min-w-0 text-[15px] leading-tight font-semibold break-words'>
|
||||
{nameCell}
|
||||
</div>
|
||||
)}
|
||||
{visibleColumnIds.has('type') && (
|
||||
<div className='mt-1.5 min-w-0'>{typeCell}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex shrink-0 items-center gap-1'>
|
||||
{visibleColumnIds.has('status') && statusCell}
|
||||
{actionsCell}
|
||||
</div>
|
||||
{nameCell}
|
||||
</div>
|
||||
|
||||
{hasStatRows && (
|
||||
<div className='mt-3 space-y-0.5 border-t pt-3'>
|
||||
{showId && (
|
||||
<DataTableCardRow label={t('ID')} contentMode='full'>
|
||||
{idCell}
|
||||
</DataTableCardRow>
|
||||
)}
|
||||
{visibleColumnIds.has('balance') && (
|
||||
<DataTableCardRow
|
||||
label={t('Used / Remaining')}
|
||||
contentMode='full'
|
||||
>
|
||||
{balanceCell}
|
||||
</DataTableCardRow>
|
||||
)}
|
||||
{visibleColumnIds.has('response_time') && (
|
||||
<DataTableCardRow label={t('Response')} contentMode='full'>
|
||||
{responseCell}
|
||||
</DataTableCardRow>
|
||||
)}
|
||||
{showTestTime && (
|
||||
<DataTableCardRow label={t('Last Tested')} contentMode='full'>
|
||||
{testCell}
|
||||
</DataTableCardRow>
|
||||
)}
|
||||
{visibleColumnIds.has('priority') && (
|
||||
<DataTableCardRow label={t('Priority')} contentMode='full'>
|
||||
{priorityCell}
|
||||
</DataTableCardRow>
|
||||
)}
|
||||
{visibleColumnIds.has('weight') && (
|
||||
<DataTableCardRow label={t('Weight')} contentMode='full'>
|
||||
{weightCell}
|
||||
</DataTableCardRow>
|
||||
)}
|
||||
{showTag && (
|
||||
<DataTableCardRow label={t('Tag')} contentMode='wrap'>
|
||||
{tagCell}
|
||||
</DataTableCardRow>
|
||||
)}
|
||||
<div className='min-w-0'>
|
||||
<div className={cn('mb-1', labelClass)}>{fieldLabels.balance}</div>
|
||||
<div className='min-w-0 text-sm'>
|
||||
{balanceCell ?? <span className='text-muted-foreground'>-</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasBadgeSections && (
|
||||
<div className='mt-3 space-y-3 border-t pt-3'>
|
||||
{visibleColumnIds.has('group') && (
|
||||
<DataTableCardField label={t('Groups')} contentMode='full'>
|
||||
{groupsCell ?? (
|
||||
<span className='text-muted-foreground'>-</span>
|
||||
)}
|
||||
</DataTableCardField>
|
||||
)}
|
||||
{showModels && (
|
||||
<DataTableCardField label={t('Models')} contentMode='full'>
|
||||
{modelsCell ?? (
|
||||
<span className='text-muted-foreground'>-</span>
|
||||
)}
|
||||
</DataTableCardField>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</BadgeListCellDisplayContext.Provider>
|
||||
</ChannelRowActionsLayoutContext.Provider>
|
||||
|
||||
<div className='grid shrink-0 grid-cols-[auto_auto] items-center gap-x-3 gap-y-1'>
|
||||
<span className={labelClass}>{t('Priority')}</span>
|
||||
<span className={labelClass}>{t('Weight')}</span>
|
||||
<div className='flex justify-start'>{priorityCell}</div>
|
||||
<div className='flex justify-start'>{weightCell}</div>
|
||||
<span className={cn('mt-2', labelClass)}>
|
||||
{fieldLabels.response_time}
|
||||
</span>
|
||||
<span className={cn('mt-2', labelClass)}>
|
||||
{fieldLabels.test_time}
|
||||
</span>
|
||||
<div className='text-sm'>
|
||||
{responseCell ?? <span className='text-muted-foreground'>-</span>}
|
||||
</div>
|
||||
<div className='text-sm'>
|
||||
{testCell ?? <span className='text-muted-foreground'>-</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='min-w-0'>
|
||||
{groups.length > 0 ? (
|
||||
<div className='-ml-1.5 flex flex-wrap gap-1'>
|
||||
{groups.map((group) => (
|
||||
<GroupBadge
|
||||
key={group}
|
||||
group={group}
|
||||
label={sensitiveVisible ? undefined : SENSITIVE_MASK}
|
||||
size='sm'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ChannelCard = ChannelCardComponent
|
||||
export const ChannelCard = memo(ChannelCardComponent)
|
||||
|
||||
@@ -456,7 +456,7 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
variant={remainingBadgeVariant}
|
||||
size='sm'
|
||||
render={<button type='button' />}
|
||||
className='cursor-pointer'
|
||||
className='cursor-pointer underline-offset-2 hover:underline'
|
||||
onClick={handleClickUpdate}
|
||||
>
|
||||
{remainingBadgeLabel}
|
||||
@@ -732,7 +732,7 @@ export function useChannelsColumns(
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className='flex max-w-full min-w-0 items-center gap-2 overflow-hidden'>
|
||||
<div className='flex w-max items-center gap-2'>
|
||||
{isMultiKey && (
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
@@ -751,15 +751,10 @@ export function useChannelsColumns(
|
||||
)}
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div className='max-w-full min-w-0 overflow-hidden' />
|
||||
}
|
||||
>
|
||||
<TooltipTrigger render={<div className='shrink-0' />}>
|
||||
<ProviderBadge
|
||||
iconKey={`${iconName}.Color`}
|
||||
label={typeName}
|
||||
className='max-w-full min-w-0 overflow-hidden'
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>{typeName}</TooltipContent>
|
||||
|
||||
@@ -163,13 +163,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
layout === 'card'
|
||||
? 'flex items-center'
|
||||
: '-ml-1.5 flex items-center gap-1'
|
||||
}
|
||||
>
|
||||
<div className='-ml-1.5 flex items-center gap-1'>
|
||||
{layout !== 'card' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
@@ -191,54 +185,71 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{layout !== 'card' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={handleDirectTest}
|
||||
disabled={isTesting}
|
||||
aria-label={t('Test Connection')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isTesting ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
<Gauge className='size-4' />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Test Connection')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{layout === 'card' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={handleDirectTest}
|
||||
disabled={isTesting}
|
||||
aria-label={t('Test Connection')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleTest()
|
||||
}}
|
||||
aria-label={t('Test Channel Connection')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isTesting ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
<Gauge className='size-4' />
|
||||
)}
|
||||
<PlugZap className='size-4' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Test Connection')}</TooltipContent>
|
||||
<TooltipContent>{t('Test Channel Connection')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{layout !== 'card' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={handleToggleStatus}
|
||||
disabled={isTogglingStatus}
|
||||
aria-label={isEnabled ? t('Disable') : t('Enable')}
|
||||
className={
|
||||
isEnabled
|
||||
? 'text-destructive hover:text-destructive'
|
||||
: 'text-success hover:text-success'
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{statusIcon}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isEnabled ? t('Disable') : t('Enable')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={handleToggleStatus}
|
||||
disabled={isTogglingStatus}
|
||||
aria-label={isEnabled ? t('Disable') : t('Enable')}
|
||||
className={
|
||||
isEnabled
|
||||
? 'text-destructive hover:text-destructive'
|
||||
: 'text-success hover:text-success'
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{statusIcon}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isEnabled ? t('Disable') : t('Enable')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -270,21 +281,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
<PlugZap size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
{layout === 'card' && (
|
||||
<DropdownMenuItem
|
||||
disabled={isTogglingStatus}
|
||||
onClick={() => void handleToggleStatus()}
|
||||
className={
|
||||
isEnabled
|
||||
? 'text-destructive focus:text-destructive'
|
||||
: 'text-success focus:text-success'
|
||||
}
|
||||
>
|
||||
{isEnabled ? t('Disable') : t('Enable')}
|
||||
<DropdownMenuShortcut>{statusIcon}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Query Balance */}
|
||||
<DropdownMenuItem onClick={handleQueryBalance}>
|
||||
{t('Query Balance')}
|
||||
|
||||
+2
-2
@@ -1190,7 +1190,7 @@ function TestStatusCell({ result }: { result?: TestResult }) {
|
||||
return (
|
||||
<StatusBadge variant='info'>
|
||||
<Loader2 className='size-3.5 shrink-0 animate-spin' />
|
||||
<span className='min-w-0 truncate leading-normal'>
|
||||
<span className='leading-normal whitespace-nowrap'>
|
||||
{t('Testing...')}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
@@ -1363,7 +1363,7 @@ function FailureDetailsSheet({
|
||||
onClick={() => copyToClipboard(details.details)}
|
||||
>
|
||||
{copiedText === details.details ? (
|
||||
<Check className='mr-2 h-4 w-4 text-green-600' />
|
||||
<Check className='text-success mr-2 h-4 w-4' />
|
||||
) : (
|
||||
<Copy className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
|
||||
+1
-1
@@ -1057,7 +1057,7 @@ export function CodexUsageDialog({
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{errorMessage && (
|
||||
<div className='rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400'>
|
||||
<div className='border-destructive/25 bg-destructive/8 text-status-destructive rounded-lg border px-4 py-3 text-sm'>
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Vendored
+1
-1
@@ -76,7 +76,7 @@ export function MissingModelsConfirmationDialog({
|
||||
'The following models in the model redirect have not been added to the "Models" list and may fail during invocation due to missing available models:'
|
||||
)}
|
||||
</div>
|
||||
<div className='rounded-md bg-red-50 p-2 font-mono text-xs break-all text-red-600 dark:bg-red-950/50 dark:text-red-400'>
|
||||
<div className='bg-destructive/8 text-status-destructive rounded-md p-2 font-mono text-xs break-all'>
|
||||
{missingModels.join(', ')}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
-1
@@ -964,7 +964,6 @@ const validateOperations = (
|
||||
if (headers.length === 0)
|
||||
return t('Rule {{line}} pass_headers format is invalid', { line })
|
||||
}
|
||||
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function NumericSpinnerInput({
|
||||
const commitValue = () => {
|
||||
setEditing(false)
|
||||
const num = Number(localValue)
|
||||
if (isNaN(num) || localValue === '' || localValue === '-') {
|
||||
if (Number.isNaN(num) || localValue === '' || localValue === '-') {
|
||||
setLocalValue(String(value ?? 0))
|
||||
return
|
||||
}
|
||||
|
||||
+1
-1
@@ -219,7 +219,7 @@ function ModelBadge(props: { model: PerfModelSummary }) {
|
||||
|
||||
return (
|
||||
<StatusBadge variant={variant}>
|
||||
<span className='mr-1 max-w-[10rem] truncate'>{model.model_name}</span>
|
||||
<span className='mr-1 whitespace-nowrap'>{model.model_name}</span>
|
||||
<span className='tabular-nums'>
|
||||
{formatUptimePct(model.success_rate)}
|
||||
</span>
|
||||
|
||||
+4
-4
@@ -23,12 +23,12 @@ import type { PingStatus } from '@/features/dashboard/types'
|
||||
*/
|
||||
export function getLatencyColorClass(latency: number): string {
|
||||
if (latency < 200) {
|
||||
return 'text-green-600 dark:text-green-400'
|
||||
return 'text-status-success'
|
||||
}
|
||||
if (latency < 500) {
|
||||
return 'text-yellow-600 dark:text-yellow-400'
|
||||
return 'text-status-warning'
|
||||
}
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
return 'text-status-destructive'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,7 +46,7 @@ export async function testUrlLatency(url: string): Promise<PingStatus> {
|
||||
const latency = Math.round(endTime - startTime)
|
||||
|
||||
return { latency, testing: false, error: false }
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return { latency: null, testing: false, error: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
if (isLoading) {
|
||||
copyIcon = <Loader2 className='size-3.5 animate-spin' />
|
||||
} else if (isCopied) {
|
||||
copyIcon = <Check className='size-3.5 text-green-600' />
|
||||
copyIcon = <Check className='text-success size-3.5' />
|
||||
}
|
||||
|
||||
let copyTooltip = t('Copy API key')
|
||||
|
||||
@@ -74,7 +74,7 @@ function LoadingStep({
|
||||
{status === 'loading' && (
|
||||
<Loader2 className='text-primary h-5 w-5 animate-spin' />
|
||||
)}
|
||||
{status === 'done' && <CheckCircle2 className='h-5 w-5 text-green-500' />}
|
||||
{status === 'done' && <CheckCircle2 className='text-success h-5 w-5' />}
|
||||
{status === 'pending' && (
|
||||
<Circle className='text-muted-foreground/40 h-5 w-5' />
|
||||
)}
|
||||
@@ -172,8 +172,8 @@ export function DeploymentAccessGuard({
|
||||
return (
|
||||
<div className='mx-auto mt-8 max-w-md'>
|
||||
<div className='text-center'>
|
||||
<div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-red-100 dark:bg-red-900/20'>
|
||||
<WifiOff className='h-8 w-8 text-red-600 dark:text-red-400' />
|
||||
<div className='bg-destructive/10 text-destructive mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl'>
|
||||
<WifiOff className='size-8' />
|
||||
</div>
|
||||
<h3 className='mb-6 text-xl font-semibold'>
|
||||
{t('Connection failed')}
|
||||
@@ -199,5 +199,5 @@ export function DeploymentAccessGuard({
|
||||
)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
return children
|
||||
}
|
||||
|
||||
@@ -78,12 +78,7 @@ export function useDeploymentsColumns(opts: {
|
||||
cell: ({ getValue }) => {
|
||||
const name = String(getValue() || '-') || '-'
|
||||
return (
|
||||
<CopyableStatusBadge
|
||||
value={name}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
|
||||
>
|
||||
<CopyableStatusBadge value={name} variant='neutral' size='sm'>
|
||||
{name}
|
||||
</CopyableStatusBadge>
|
||||
)
|
||||
@@ -131,11 +126,7 @@ export function useDeploymentsColumns(opts: {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
return (
|
||||
<StatusBadge
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
|
||||
>
|
||||
<StatusBadge variant='neutral' size='sm'>
|
||||
{String(provider)}
|
||||
</StatusBadge>
|
||||
)
|
||||
@@ -232,7 +223,6 @@ export function useDeploymentsColumns(opts: {
|
||||
value={String(hardware)}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
|
||||
>
|
||||
{String(hardware)}
|
||||
</CopyableStatusBadge>
|
||||
|
||||
@@ -155,28 +155,32 @@ export function ViewLogsDialog({
|
||||
if (isLoadingContainers || isLoadingLogs) {
|
||||
logsContent = (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
|
||||
<Loader2 className='text-muted-foreground size-6 animate-spin' />
|
||||
</div>
|
||||
)
|
||||
} else if (containers.length === 0) {
|
||||
logsContent = (
|
||||
<div className='py-8 text-center text-gray-400'>{t('No containers')}</div>
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
{t('No containers')}
|
||||
</div>
|
||||
)
|
||||
} else if (!containerId) {
|
||||
logsContent = (
|
||||
<div className='py-8 text-center text-gray-400'>
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
{t('Please select a container')}
|
||||
</div>
|
||||
)
|
||||
} else if (!logsText.trim()) {
|
||||
logsContent = (
|
||||
<div className='py-8 text-center text-gray-400'>{t('No logs')}</div>
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
{t('No logs')}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
logsContent = (
|
||||
<div className='font-mono text-sm'>
|
||||
{keyedLogLines.map(({ key, line }) => (
|
||||
<div key={key} className='whitespace-pre-wrap text-gray-200'>
|
||||
<div key={key} className='text-foreground whitespace-pre-wrap'>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -143,12 +143,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
cell: ({ row }) => {
|
||||
const name = row.getValue('model_name') as string
|
||||
return (
|
||||
<CopyableStatusBadge
|
||||
value={name}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
|
||||
>
|
||||
<CopyableStatusBadge value={name} variant='neutral' size='sm'>
|
||||
{name}
|
||||
</CopyableStatusBadge>
|
||||
)
|
||||
@@ -255,11 +250,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
|
||||
return (
|
||||
<BadgeCell className='overflow-visible'>
|
||||
<ProviderBadge
|
||||
iconKey={vendor.icon}
|
||||
label={vendor.name}
|
||||
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
|
||||
/>
|
||||
<ProviderBadge iconKey={vendor.icon} label={vendor.name} />
|
||||
</BadgeCell>
|
||||
)
|
||||
},
|
||||
|
||||
+1
-4
@@ -123,10 +123,7 @@ function PlaygroundParameterContent({
|
||||
>
|
||||
{t(control.labelKey)}
|
||||
</label>
|
||||
<StatusBadge
|
||||
appearance='outline'
|
||||
className='max-w-24 shrink-0'
|
||||
>
|
||||
<StatusBadge appearance='outline'>
|
||||
{t(getParameterControlValueText(control.key, value))}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
@@ -110,7 +110,7 @@ export function MessageActions({
|
||||
|
||||
if (hasContent) {
|
||||
actions.push({
|
||||
className: isCopied ? 'text-green-600' : '',
|
||||
className: isCopied ? 'text-success' : '',
|
||||
icon: isCopied ? Check : Copy,
|
||||
label: isCopied
|
||||
? MESSAGE_ACTION_LABELS.COPIED
|
||||
|
||||
@@ -63,7 +63,7 @@ export function MessageError({
|
||||
|
||||
return (
|
||||
<Alert variant='default' className={className}>
|
||||
<AlertTriangle className='text-orange-500' />
|
||||
<AlertTriangle className='text-warning' />
|
||||
<AlertTitle>{t('Model Price Not Configured')}</AlertTitle>
|
||||
<AlertDescription className='space-y-2'>
|
||||
<p>{content}</p>
|
||||
|
||||
+25
-22
@@ -260,7 +260,7 @@ export function DynamicPricingBreakdown({
|
||||
{t('Tiered price table')}
|
||||
</div>
|
||||
<div className='space-y-1.5 sm:hidden'>
|
||||
{tiers.map((tier, i) => {
|
||||
{tiers.map((tier) => {
|
||||
const condSummary = formatConditionSummary(tier.conditions, t)
|
||||
const isMatched =
|
||||
matchedTierLabel != null &&
|
||||
@@ -268,7 +268,7 @@ export function DynamicPricingBreakdown({
|
||||
tier.label === matchedTierLabel
|
||||
return (
|
||||
<div
|
||||
key={`tier-mobile-${i}`}
|
||||
key={`tier-mobile-${tier.label || condSummary || 'default'}`}
|
||||
className={cn(
|
||||
'rounded-md border p-2',
|
||||
isMatched && 'border-success/40 bg-success/10'
|
||||
@@ -416,27 +416,30 @@ export function DynamicPricingBreakdown({
|
||||
{t('Conditional multipliers')}
|
||||
</div>
|
||||
<ul className='space-y-1.5'>
|
||||
{ruleGroups.map((group, gi) => (
|
||||
<li
|
||||
key={`group-${gi}`}
|
||||
className='bg-muted/50 flex items-center justify-between gap-3 rounded-md px-3 py-2'
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'text-foreground break-all',
|
||||
compact ? 'text-xs' : 'text-sm'
|
||||
)}
|
||||
{ruleGroups.map((group) => {
|
||||
const description = describeGroup(group, t)
|
||||
return (
|
||||
<li
|
||||
key={`${description}-${group.multiplier}`}
|
||||
className='bg-muted/50 flex items-center justify-between gap-3 rounded-md px-3 py-2'
|
||||
>
|
||||
{describeGroup(group, t)}
|
||||
</span>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='shrink-0 bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
|
||||
>
|
||||
{group.multiplier}x
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
<span
|
||||
className={cn(
|
||||
'text-foreground break-all',
|
||||
compact ? 'text-xs' : 'text-sm'
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='border-warning/30 bg-warning/10 text-status-warning shrink-0'
|
||||
>
|
||||
{group.multiplier}x
|
||||
</Badge>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+2
-2
@@ -60,8 +60,8 @@ export function TelegramBindDialog({
|
||||
</Alert>
|
||||
|
||||
<div className='flex flex-col items-center justify-center gap-4 rounded-lg border p-6'>
|
||||
<div className='flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 dark:bg-blue-900'>
|
||||
<Send className='h-6 w-6 text-blue-600 dark:text-blue-400' />
|
||||
<div className='bg-info/10 text-info flex h-12 w-12 items-center justify-center rounded-xl'>
|
||||
<Send className='size-6' />
|
||||
</div>
|
||||
|
||||
<div className='text-center'>
|
||||
|
||||
@@ -42,9 +42,9 @@ export function AdminStep({ form, rootInitialized }: AdminStepProps) {
|
||||
const { t } = useTranslation()
|
||||
if (rootInitialized) {
|
||||
return (
|
||||
<Alert className='border-sky-200 bg-sky-50 dark:border-sky-900/60 dark:bg-sky-950/40'>
|
||||
<Alert className='border-info/25 bg-info/8'>
|
||||
<AlertDescription className='flex items-start gap-2'>
|
||||
<ShieldCheck className='mt-0.5 size-4 text-sky-500' />
|
||||
<ShieldCheck className='text-info mt-0.5 size-4' />
|
||||
{t(
|
||||
'The administrator account is already initialized. You can keep your existing credentials and continue to the next step.'
|
||||
)}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function CompleteStep({ status, values }: CompleteStepProps) {
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center gap-6 text-center'>
|
||||
<div className='rounded-2xl bg-emerald-500/10 p-4 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-300'>
|
||||
<div className='bg-success/10 text-success rounded-xl p-4'>
|
||||
<CheckCircle2 className='size-8' />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
|
||||
@@ -107,9 +107,9 @@ export function DatabaseStep({ status }: DatabaseStepProps) {
|
||||
</div>
|
||||
|
||||
{status?.database_type === 'sqlite' && (
|
||||
<Alert className='border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/40'>
|
||||
<Alert className='border-warning/30 bg-warning/8'>
|
||||
<AlertTitle className='flex items-center gap-2'>
|
||||
<HardDrive className='size-4 text-amber-500' />
|
||||
<HardDrive className='text-warning size-4' />
|
||||
{t('Persist your data file')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
@@ -119,7 +119,7 @@ export function DatabaseStep({ status }: DatabaseStepProps) {
|
||||
)}
|
||||
</p>
|
||||
{isElectron && electronDataDir && (
|
||||
<p className='mt-3 rounded-md bg-amber-100/70 px-3 py-2 font-mono text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-200'>
|
||||
<p className='bg-warning/10 text-status-warning mt-3 rounded-md px-3 py-2 font-mono text-xs'>
|
||||
{t('Data directory:')} {electronDataDir}
|
||||
</p>
|
||||
)}
|
||||
@@ -135,9 +135,9 @@ export function DatabaseStep({ status }: DatabaseStepProps) {
|
||||
)}
|
||||
|
||||
{status?.database_type === 'mysql' && (
|
||||
<Alert className='border-emerald-200 bg-emerald-50 dark:border-emerald-900/60 dark:bg-emerald-950/40'>
|
||||
<Alert className='border-success/25 bg-success/8'>
|
||||
<AlertTitle className='flex items-center gap-2'>
|
||||
<Server className='size-4 text-emerald-500' />
|
||||
<Server className='text-success size-4' />
|
||||
{t('MySQL detected')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
@@ -149,9 +149,9 @@ export function DatabaseStep({ status }: DatabaseStepProps) {
|
||||
)}
|
||||
|
||||
{status?.database_type === 'postgres' && (
|
||||
<Alert className='border-sky-200 bg-sky-50 dark:border-sky-900/60 dark:bg-sky-950/40'>
|
||||
<Alert className='border-info/25 bg-info/8'>
|
||||
<AlertTitle className='flex items-center gap-2'>
|
||||
<Server className='size-4 text-sky-500' />
|
||||
<Server className='text-info size-4' />
|
||||
{t('PostgreSQL detected')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
|
||||
@@ -100,31 +100,31 @@ const typeOptions = [
|
||||
{
|
||||
value: 'default',
|
||||
label: 'Default',
|
||||
color: 'bg-gray-500',
|
||||
color: 'bg-neutral',
|
||||
badgeVariant: 'neutral' as const,
|
||||
},
|
||||
{
|
||||
value: 'ongoing',
|
||||
label: 'Ongoing',
|
||||
color: 'bg-blue-500',
|
||||
color: 'bg-info',
|
||||
badgeVariant: 'info' as const,
|
||||
},
|
||||
{
|
||||
value: 'success',
|
||||
label: 'Success',
|
||||
color: 'bg-green-500',
|
||||
color: 'bg-success',
|
||||
badgeVariant: 'success' as const,
|
||||
},
|
||||
{
|
||||
value: 'warning',
|
||||
label: 'Warning',
|
||||
color: 'bg-orange-500',
|
||||
color: 'bg-warning',
|
||||
badgeVariant: 'warning' as const,
|
||||
},
|
||||
{
|
||||
value: 'error',
|
||||
label: 'Error',
|
||||
color: 'bg-red-500',
|
||||
color: 'bg-destructive',
|
||||
badgeVariant: 'destructive' as const,
|
||||
},
|
||||
]
|
||||
|
||||
+3
-3
@@ -156,7 +156,7 @@ export function AmountDiscountDialog({
|
||||
placeholder={t('e.g., 100')}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value) || 0)
|
||||
field.onChange(Number.parseInt(e.target.value) || 0)
|
||||
}
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
@@ -188,14 +188,14 @@ export function AmountDiscountDialog({
|
||||
placeholder={t('e.g., 0.95')}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value) || 0)
|
||||
field.onChange(Number.parseFloat(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Final price multiplier (0.95 = 5% discount')}
|
||||
{discountPercentage > 0 && (
|
||||
<span className='ml-1 font-medium text-green-600 dark:text-green-400'>
|
||||
<span className='text-status-success ml-1 font-medium'>
|
||||
= {discountPercentage}
|
||||
{t('% off')}
|
||||
</span>
|
||||
|
||||
Vendored
+1
-1
@@ -234,7 +234,7 @@ export function IoNetDeploymentSettingsSection({
|
||||
|
||||
{testState.ok === true ? (
|
||||
<Alert variant='default' className='flex items-center gap-2'>
|
||||
<CheckCircle2 className='size-4 text-green-600' />
|
||||
<CheckCircle2 className='text-success size-4' />
|
||||
<div>
|
||||
<AlertTitle>{t('Connection successful')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
|
||||
+6
-6
@@ -1251,24 +1251,24 @@ export function PaymentSettingsSection({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md bg-blue-50 p-4 text-sm text-blue-900 dark:bg-blue-950 dark:text-blue-100'>
|
||||
<div className='border-info/25 bg-info/8 rounded-md border p-4 text-sm'>
|
||||
<p className='mb-2 font-medium'>
|
||||
{t('Webhook Configuration:')}
|
||||
</p>
|
||||
<ul className='list-inside list-disc space-y-1'>
|
||||
<li>
|
||||
{t('Webhook URL:')}{' '}
|
||||
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
|
||||
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
|
||||
{'<ServerAddress>/api/stripe/webhook'}
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
{t('Required events:')}{' '}
|
||||
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
|
||||
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
|
||||
{t('checkout.session.completed')}
|
||||
</code>{' '}
|
||||
{t('and')}{' '}
|
||||
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
|
||||
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
|
||||
{t('checkout.session.expired')}
|
||||
</code>
|
||||
</li>
|
||||
@@ -1443,14 +1443,14 @@ export function PaymentSettingsSection({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md bg-blue-50 p-4 text-sm text-blue-900 dark:bg-blue-950 dark:text-blue-100'>
|
||||
<div className='border-info/25 bg-info/8 rounded-md border p-4 text-sm'>
|
||||
<p className='mb-2 font-medium'>
|
||||
{t('Webhook Configuration:')}
|
||||
</p>
|
||||
<ul className='list-inside list-disc space-y-1'>
|
||||
<li>
|
||||
{t('Webhook URL:')}{' '}
|
||||
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
|
||||
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
|
||||
{'<ServerAddress>/api/creem/webhook'}
|
||||
</code>
|
||||
</li>
|
||||
|
||||
+5
-5
@@ -384,19 +384,19 @@ export function WaffoPancakeSettingsSection({
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid min-w-0 gap-x-5 gap-y-4 lg:grid-cols-2'>
|
||||
{/* Blue box — webhook configuration only. */}
|
||||
<div className='rounded-md bg-blue-50 p-4 text-sm text-blue-900 lg:col-span-2 dark:bg-blue-950 dark:text-blue-100'>
|
||||
{/* Webhook configuration notice. */}
|
||||
<div className='border-info/25 bg-info/8 rounded-md border p-4 text-sm lg:col-span-2'>
|
||||
<p className='mb-2 font-medium'>{t('Webhook Configuration:')}</p>
|
||||
<ul className='list-inside list-disc space-y-1'>
|
||||
<li>
|
||||
{t('Webhook URL (Test):')}{' '}
|
||||
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
|
||||
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
|
||||
{'<ServerAddress>/api/waffo-pancake/webhook/test'}
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
{t('Webhook URL (Production):')}{' '}
|
||||
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
|
||||
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
|
||||
{'<ServerAddress>/api/waffo-pancake/webhook/prod'}
|
||||
</code>
|
||||
</li>
|
||||
@@ -474,7 +474,7 @@ export function WaffoPancakeSettingsSection({
|
||||
for. Subscriptions reuse the same Store but get their own
|
||||
per-plan product, configured in the Subscriptions admin.
|
||||
*/}
|
||||
<div className='rounded-md border border-blue-200 bg-blue-50 p-3 text-xs text-blue-900 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-100'>
|
||||
<div className='border-info/25 bg-info/8 rounded-md border p-3 text-xs'>
|
||||
<p className='mb-1 font-medium'>
|
||||
{t('Why only one store + product?')}
|
||||
</p>
|
||||
|
||||
@@ -86,7 +86,7 @@ export function createTimestampColumn<T>(config: {
|
||||
cell: ({ row }) => {
|
||||
const timestamp = row.getValue(accessorKey) as number
|
||||
if (!timestamp) {
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
}
|
||||
return (
|
||||
<span className='text-xs tabular-nums'>
|
||||
@@ -135,7 +135,7 @@ export function createDurationColumn<T>(config: {
|
||||
)
|
||||
|
||||
if (!duration) {
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const variant =
|
||||
@@ -173,7 +173,7 @@ export function createChannelColumn<T>(config: {
|
||||
cell: ({ row }) => {
|
||||
const channelId = row.getValue(accessorKey) as number
|
||||
if (!channelId) {
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
}
|
||||
return (
|
||||
<CopyableStatusBadge
|
||||
@@ -215,7 +215,7 @@ export function createFailReasonColumn<T>(config: {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
if (!failReason) {
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -265,7 +265,7 @@ export function createProgressColumn<T>(config: {
|
||||
cell: ({ row }) => {
|
||||
const progress = row.getValue(accessorKey) as string
|
||||
if (!progress) {
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
}
|
||||
return (
|
||||
<StatusBadge variant='neutral' className='font-mono'>
|
||||
|
||||
+12
-17
@@ -442,7 +442,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
)}
|
||||
</div>
|
||||
{log.channel_name && (
|
||||
<span className='text-muted-foreground/70 truncate text-xs'>
|
||||
<span className='text-subtle-foreground truncate text-xs'>
|
||||
{channelName}
|
||||
</span>
|
||||
)}
|
||||
@@ -579,25 +579,20 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
if (groupRatioText) metaParts.push(groupRatioText)
|
||||
|
||||
return (
|
||||
<div className='flex max-w-[200px] flex-col gap-0.5'>
|
||||
<div className='flex w-max flex-col gap-0.5'>
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div className='max-w-full' />}>
|
||||
<TooltipTrigger render={<div className='shrink-0' />}>
|
||||
{sensitiveVisible ? (
|
||||
<CopyableStatusBadge
|
||||
value={tokenName}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
className='max-w-full'
|
||||
>
|
||||
{displayName}
|
||||
</CopyableStatusBadge>
|
||||
) : (
|
||||
<StatusBadge
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
className='max-w-full'
|
||||
>
|
||||
<StatusBadge variant='neutral' size='sm'>
|
||||
{displayName}
|
||||
</StatusBadge>
|
||||
)}
|
||||
@@ -610,7 +605,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{metaParts.length > 0 && (
|
||||
<span className='text-muted-foreground/60 text-xs [overflow-wrap:anywhere] break-words'>
|
||||
<span className='text-subtle-foreground text-xs [overflow-wrap:anywhere] break-words'>
|
||||
{metaParts.join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
@@ -700,7 +695,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
))}
|
||||
</div>
|
||||
<div className='flex items-center gap-1 text-xs leading-none'>
|
||||
<span className='text-muted-foreground/60 text-xs leading-none'>
|
||||
<span className='text-subtle-foreground text-xs leading-none'>
|
||||
{log.is_stream ? t('Stream') : t('Non-stream')}
|
||||
{tokensPerSecond != null && (
|
||||
<>
|
||||
@@ -782,12 +777,12 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
{(cacheReadTokens > 0 || cacheWriteTokens > 0) && (
|
||||
<div className='flex items-center gap-1 text-xs'>
|
||||
{cacheReadTokens > 0 && (
|
||||
<span className='text-muted-foreground/60'>
|
||||
<span className='text-subtle-foreground'>
|
||||
{t('Cache')}↓ {cacheReadTokens.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{cacheWriteTokens > 0 && (
|
||||
<span className='text-muted-foreground/60'>
|
||||
<span className='text-subtle-foreground'>
|
||||
↑ {cacheWriteTokens.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
@@ -860,7 +855,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
const segments = buildDetailSegments(log, other, t, isAdmin)
|
||||
const primary = segments[0]
|
||||
const hasMore = segments.length > 1
|
||||
let detailsContent = <span className='text-muted-foreground/40'>—</span>
|
||||
let detailsContent = <span className='text-faint-foreground'>—</span>
|
||||
|
||||
if (log.content) {
|
||||
detailsContent = (
|
||||
@@ -873,7 +868,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
if (primary) {
|
||||
let primaryClassName = 'text-foreground'
|
||||
if (primary.muted) {
|
||||
primaryClassName = 'text-muted-foreground/60'
|
||||
primaryClassName = 'text-subtle-foreground'
|
||||
} else if (primary.danger) {
|
||||
primaryClassName = 'text-destructive'
|
||||
}
|
||||
@@ -887,7 +882,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
>
|
||||
{primary.text}
|
||||
{hasMore && (
|
||||
<span className='text-muted-foreground/40 ml-0.5'>
|
||||
<span className='text-faint-foreground ml-0.5'>
|
||||
+{segments.length - 1}
|
||||
</span>
|
||||
)}
|
||||
@@ -905,7 +900,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
>
|
||||
{detailsContent}
|
||||
{ip && (
|
||||
<span className='text-muted-foreground/60 max-w-full truncate tabular-nums'>
|
||||
<span className='text-subtle-foreground max-w-full truncate tabular-nums'>
|
||||
{ip}
|
||||
</span>
|
||||
)}
|
||||
|
||||
+5
-5
@@ -149,16 +149,16 @@ export function useDrawingLogsColumns(
|
||||
const mjId = row.getValue('mj_id') as string
|
||||
|
||||
if (!mjId) {
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex max-w-[160px] flex-col gap-0.5'>
|
||||
<div className='flex w-max flex-col gap-0.5'>
|
||||
<CopyableStatusBadge
|
||||
value={mjId}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
className='h-auto max-w-full overflow-visible font-mono [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
|
||||
className='font-mono'
|
||||
>
|
||||
{mjId}
|
||||
</CopyableStatusBadge>
|
||||
@@ -215,7 +215,7 @@ export function useDrawingLogsColumns(
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
if (!imageUrl) {
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -254,7 +254,7 @@ export function useDrawingLogsColumns(
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
if (!prompt) {
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+51
-133
@@ -17,9 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { Music } from 'lucide-react'
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { CopyableStatusBadge, StatusBadge } from '@/components/status-badge'
|
||||
@@ -28,14 +27,13 @@ import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { TASK_ACTIONS, TASK_STATUS } from '../../constants'
|
||||
import { taskActionMapper, taskStatusMapper } from '../../lib/mappers'
|
||||
import type { TaskLog } from '../../types'
|
||||
import {
|
||||
AudioPreviewDialog,
|
||||
type AudioClip,
|
||||
} from '../dialogs/audio-preview-dialog'
|
||||
import { FailReasonDialog } from '../dialogs/fail-reason-dialog'
|
||||
getTaskPlatformName,
|
||||
taskActionMapper,
|
||||
taskStatusMapper,
|
||||
} from '../../lib/mappers'
|
||||
import type { TaskLog } from '../../types'
|
||||
import { TaskDetailsDialog } from '../dialogs/task-details-dialog'
|
||||
import { useUsageLogsContext } from '../usage-logs-provider'
|
||||
import {
|
||||
createDurationColumn,
|
||||
@@ -43,53 +41,6 @@ import {
|
||||
createProgressColumn,
|
||||
} from './column-helpers'
|
||||
|
||||
function parseTaskData(data: unknown): unknown[] {
|
||||
if (Array.isArray(data)) return data
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function AudioPreviewCell({ log }: { log: TaskLog }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const clips = useMemo(() => {
|
||||
const data = parseTaskData(log.data)
|
||||
return data.filter(
|
||||
(c) =>
|
||||
c && typeof c === 'object' && (c as Record<string, unknown>).audio_url
|
||||
)
|
||||
}, [log.data])
|
||||
|
||||
if (clips.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
className='group flex items-center gap-1 text-left text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Music className='text-muted-foreground size-3' />
|
||||
<span className='text-foreground leading-snug group-hover:underline'>
|
||||
{t('Click to preview audio')}
|
||||
</span>
|
||||
</button>
|
||||
<AudioPreviewDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
clips={clips as AudioClip[]}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
const { t } = useTranslation()
|
||||
const columns: ColumnDef<TaskLog>[] = [
|
||||
@@ -106,11 +57,11 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
{formatTimestampToDate(submitTime, 'seconds')}
|
||||
</span>
|
||||
{log.finish_time ? (
|
||||
<span className='text-muted-foreground/60 text-xs tabular-nums'>
|
||||
<span className='text-subtle-foreground text-xs tabular-nums'>
|
||||
{formatTimestampToDate(log.finish_time, 'seconds')}
|
||||
</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground/50 text-xs'>-</span>
|
||||
<span className='text-subtle-foreground text-xs'>-</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -180,20 +131,21 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
const log = row.original
|
||||
const taskId = row.getValue('task_id') as string
|
||||
if (!taskId) {
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
}
|
||||
return (
|
||||
<div className='flex max-w-[170px] flex-col gap-0.5'>
|
||||
<div className='flex w-max flex-col gap-0.5'>
|
||||
<CopyableStatusBadge
|
||||
value={taskId}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
className='h-auto max-w-full overflow-visible font-mono [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
|
||||
className='font-mono'
|
||||
>
|
||||
{taskId}
|
||||
</CopyableStatusBadge>
|
||||
<span className='text-muted-foreground/60 text-xs [overflow-wrap:anywhere] break-words'>
|
||||
{t(log.platform)} · {t(taskActionMapper.getLabel(log.action))}
|
||||
<span className='text-subtle-foreground text-xs [overflow-wrap:anywhere] break-words'>
|
||||
{getTaskPlatformName(log.platform)} ·{' '}
|
||||
{t(taskActionMapper.getLabel(log.action))}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
@@ -229,79 +181,14 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
},
|
||||
createProgressColumn<TaskLog>({ headerLabel: t('Progress') }),
|
||||
{
|
||||
accessorKey: 'fail_reason',
|
||||
id: 'details',
|
||||
header: t('Details'),
|
||||
enableSorting: false,
|
||||
cell: function DetailsCell({ row }) {
|
||||
const log = row.original
|
||||
const failReason = row.getValue('fail_reason') as string
|
||||
const status = log.status
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
const isSunoSuccess =
|
||||
log.platform === 'suno' && status === TASK_STATUS.SUCCESS
|
||||
if (isSunoSuccess) {
|
||||
const data = parseTaskData(log.data)
|
||||
if (
|
||||
data.some(
|
||||
(c) =>
|
||||
c &&
|
||||
typeof c === 'object' &&
|
||||
(c as Record<string, unknown>).audio_url
|
||||
)
|
||||
) {
|
||||
return <AudioPreviewCell log={log} />
|
||||
}
|
||||
}
|
||||
|
||||
const isVideoTask =
|
||||
log.action === TASK_ACTIONS.GENERATE ||
|
||||
log.action === TASK_ACTIONS.TEXT_GENERATE ||
|
||||
log.action === TASK_ACTIONS.FIRST_TAIL_GENERATE ||
|
||||
log.action === TASK_ACTIONS.REFERENCE_GENERATE ||
|
||||
log.action === TASK_ACTIONS.REMIX_GENERATE
|
||||
const isSuccess = status === TASK_STATUS.SUCCESS
|
||||
const isUrl = failReason?.startsWith('http')
|
||||
|
||||
if (isSuccess && isVideoTask && isUrl) {
|
||||
const videoUrl = `/v1/videos/${log.task_id}/content`
|
||||
return (
|
||||
<a
|
||||
href={videoUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-foreground text-xs hover:underline'
|
||||
>
|
||||
{t('Click to preview video')}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
if (!failReason) {
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
className='group flex max-w-[200px] items-center gap-1 text-left text-xs'
|
||||
onClick={() => setDialogOpen(true)}
|
||||
title={t('Click to view full error message')}
|
||||
>
|
||||
<span className='text-destructive truncate leading-snug group-hover:underline'>
|
||||
{failReason}
|
||||
</span>
|
||||
</button>
|
||||
<FailReasonDialog
|
||||
failReason={failReason}
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
return <TaskDetailsCell log={row.original} isAdmin={isAdmin} />
|
||||
},
|
||||
size: 200,
|
||||
maxSize: 220,
|
||||
size: 120,
|
||||
maxSize: 140,
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 20,
|
||||
@@ -313,3 +200,34 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
function TaskDetailsCell({ log, isAdmin }: { log: TaskLog; isAdmin: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const isFailed = !!log.fail_reason && log.fail_reason.trim() !== ''
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'text-xs leading-snug hover:underline',
|
||||
isFailed ? 'text-destructive' : 'text-foreground'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(true)
|
||||
}}
|
||||
title={t('View the complete details for this task')}
|
||||
>
|
||||
{t('View')}
|
||||
</button>
|
||||
<TaskDetailsDialog
|
||||
log={log}
|
||||
isAdmin={isAdmin}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+2
-46
@@ -16,15 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ExternalLink, Copy, Music } from 'lucide-react'
|
||||
import { ExternalLink, Copy } from 'lucide-react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
export interface AudioClip {
|
||||
clip_id?: string
|
||||
@@ -41,12 +39,6 @@ export interface AudioClip {
|
||||
}
|
||||
}
|
||||
|
||||
interface AudioPreviewDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
clips: AudioClip[]
|
||||
}
|
||||
|
||||
function formatDuration(seconds?: number): string {
|
||||
if (!seconds || seconds <= 0) return '--:--'
|
||||
const m = Math.floor(seconds / 60)
|
||||
@@ -54,7 +46,7 @@ function formatDuration(seconds?: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function AudioClipCard({ clip }: { clip: AudioClip }) {
|
||||
export function AudioClipCard({ clip }: { clip: AudioClip }) {
|
||||
const { t } = useTranslation()
|
||||
const [hasError, setHasError] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
@@ -135,39 +127,3 @@ function AudioClipCard({ clip }: { clip: AudioClip }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AudioPreviewDialog(props: AudioPreviewDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const clips = Array.isArray(props.clips) ? props.clips : []
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
title={
|
||||
<>
|
||||
<Music className='h-5 w-5' />
|
||||
{t('Audio Preview')}
|
||||
</>
|
||||
}
|
||||
contentClassName='sm:max-w-lg'
|
||||
titleClassName='flex items-center gap-2'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
>
|
||||
{clips.length === 0 ? (
|
||||
<p className='text-muted-foreground py-4 text-center text-sm'>
|
||||
{t('None')}
|
||||
</p>
|
||||
) : (
|
||||
<ScrollArea className='max-h-[60vh]'>
|
||||
<div className='space-y-3 pr-2'>
|
||||
{clips.map((clip, idx) => (
|
||||
<AudioClipCard key={clip.clip_id || clip.id || idx} clip={clip} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+3
-3
@@ -55,7 +55,7 @@ export function FailReasonDialog({
|
||||
<Label className='text-sm font-semibold'>
|
||||
{t('Error Message')}
|
||||
</Label>
|
||||
<div className='bg-muted/50 relative rounded-md border border-red-200 p-3'>
|
||||
<div className='border-destructive/25 bg-destructive/8 relative rounded-md border p-3'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
@@ -64,12 +64,12 @@ export function FailReasonDialog({
|
||||
title={t('Copy to clipboard')}
|
||||
>
|
||||
{copiedText === failReason ? (
|
||||
<Check className='size-4 text-green-600' />
|
||||
<Check className='text-success size-4' />
|
||||
) : (
|
||||
<Copy className='size-4' />
|
||||
)}
|
||||
</Button>
|
||||
<p className='overflow-wrap-anywhere pr-10 text-sm leading-relaxed break-all whitespace-pre-wrap text-red-600'>
|
||||
<p className='text-status-destructive overflow-wrap-anywhere pr-10 text-sm leading-relaxed break-all whitespace-pre-wrap'>
|
||||
{failReason || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@ export function PromptDialog({
|
||||
title={t('Copy to clipboard')}
|
||||
>
|
||||
{copiedText === prompt ? (
|
||||
<Check className='size-4 text-green-600' />
|
||||
<Check className='text-success size-4' />
|
||||
) : (
|
||||
<Copy className='size-4' />
|
||||
)}
|
||||
@@ -91,7 +91,7 @@ export function PromptDialog({
|
||||
title={t('Copy to clipboard')}
|
||||
>
|
||||
{copiedText === promptEn ? (
|
||||
<Check className='size-4 text-green-600' />
|
||||
<Check className='text-success size-4' />
|
||||
) : (
|
||||
<Copy className='size-4' />
|
||||
)}
|
||||
|
||||
+633
@@ -0,0 +1,633 @@
|
||||
/*
|
||||
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 {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Music,
|
||||
Video,
|
||||
} from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { formatLogQuota, formatTimestampToDate } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { TASK_ACTIONS, TASK_PLATFORMS, TASK_STATUS } from '../../constants'
|
||||
import { formatDuration } from '../../lib/format'
|
||||
import {
|
||||
getTaskPlatformName,
|
||||
taskActionMapper,
|
||||
taskStatusMapper,
|
||||
} from '../../lib/mappers'
|
||||
import type { TaskLog } from '../../types'
|
||||
import { AudioClipCard, type AudioClip } from './audio-preview-dialog'
|
||||
|
||||
const VIDEO_ACTIONS = new Set<string>([
|
||||
TASK_ACTIONS.GENERATE,
|
||||
TASK_ACTIONS.TEXT_GENERATE,
|
||||
TASK_ACTIONS.FIRST_TAIL_GENERATE,
|
||||
TASK_ACTIONS.REFERENCE_GENERATE,
|
||||
TASK_ACTIONS.REMIX_GENERATE,
|
||||
])
|
||||
|
||||
const MAX_VALUE_LENGTH = 800
|
||||
|
||||
function parseJson(raw: unknown): unknown {
|
||||
if (raw == null || raw === '') return null
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
// snake_case / camelCase -> "Title Case" with common acronym fixes, so any
|
||||
// upstream provider payload renders with readable labels.
|
||||
function humanizeKey(key: string): string {
|
||||
const spaced = key
|
||||
.replaceAll('_', ' ')
|
||||
.replaceAll(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.trim()
|
||||
return spaced
|
||||
.replaceAll(/\b\w/g, (c) => c.toUpperCase())
|
||||
.replaceAll(/\bUrl\b/g, 'URL')
|
||||
.replaceAll(/\bId\b/g, 'ID')
|
||||
.replaceAll(/\bApi\b/g, 'API')
|
||||
.replaceAll(/\bFps\b/g, 'FPS')
|
||||
}
|
||||
|
||||
function formatScalar(value: unknown): string {
|
||||
if (value == null) return '-'
|
||||
const str = typeof value === 'string' ? value : String(value)
|
||||
if (str.length > MAX_VALUE_LENGTH) return `${str.slice(0, MAX_VALUE_LENGTH)}…`
|
||||
return str
|
||||
}
|
||||
|
||||
interface FlatEntry {
|
||||
label: string
|
||||
value: string
|
||||
mono?: boolean
|
||||
}
|
||||
|
||||
// Flatten a nested upstream payload into readable rows. Nested objects are
|
||||
// prefixed with their parent label ("Cost › Currency"); arrays of scalars are
|
||||
// joined, arrays of objects are shown as compact JSON.
|
||||
function flattenEntries(
|
||||
obj: Record<string, unknown>,
|
||||
parent = ''
|
||||
): FlatEntry[] {
|
||||
const entries: FlatEntry[] = []
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const label = parent ? `${parent} › ${humanizeKey(key)}` : humanizeKey(key)
|
||||
if (isPlainObject(value)) {
|
||||
entries.push(...flattenEntries(value, label))
|
||||
} else if (Array.isArray(value)) {
|
||||
const allScalar = value.every((v) => v == null || typeof v !== 'object')
|
||||
if (allScalar) {
|
||||
entries.push({
|
||||
label,
|
||||
value: value.map(formatScalar).join(', ') || '-',
|
||||
})
|
||||
} else {
|
||||
entries.push({
|
||||
label,
|
||||
value: formatScalar(JSON.stringify(value)),
|
||||
mono: true,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
entries.push({
|
||||
label,
|
||||
value: formatScalar(value),
|
||||
mono: typeof value !== 'boolean',
|
||||
})
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// The DTO's result_url falls back to fail_reason when no dedicated URL is
|
||||
// stored, so only treat http(s) links or the internal video proxy path as a
|
||||
// playable URL.
|
||||
function resolveVideoUrl(log: TaskLog): string {
|
||||
const raw = (log.result_url ?? '').trim()
|
||||
const isHttp = /^https?:\/\//i.test(raw)
|
||||
const isProxy = raw.startsWith('/v1/videos/')
|
||||
if (isHttp || isProxy) return raw
|
||||
if (VIDEO_ACTIONS.has(log.action) && log.status === TASK_STATUS.SUCCESS) {
|
||||
if (log.task_id) return `/v1/videos/${log.task_id}/content`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function DetailRow(props: {
|
||||
label: React.ReactNode
|
||||
value: React.ReactNode
|
||||
mono?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className='grid min-w-0 grid-cols-[6rem_minmax(0,1fr)] gap-2 text-sm sm:grid-cols-[8rem_minmax(0,1fr)] sm:gap-3'>
|
||||
<span className='text-muted-foreground min-w-0 text-xs'>
|
||||
{props.label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'max-w-full min-w-0 text-xs break-all sm:wrap-break-word',
|
||||
props.mono && 'font-mono'
|
||||
)}
|
||||
>
|
||||
{props.value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailSection(props: {
|
||||
icon?: React.ReactNode
|
||||
label: string
|
||||
variant?: 'default' | 'destructive'
|
||||
action?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const isDestructive = props.variant === 'destructive'
|
||||
return (
|
||||
<div className='min-w-0 space-y-1.5'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<Label
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-xs font-semibold',
|
||||
isDestructive && 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{props.icon}
|
||||
{props.label}
|
||||
</Label>
|
||||
{props.action}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 space-y-1 overflow-hidden rounded-md border p-2.5 max-sm:p-2',
|
||||
isDestructive
|
||||
? 'border-destructive/25 bg-destructive/10'
|
||||
: 'bg-muted/30'
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyButton(props: {
|
||||
text: string
|
||||
copied: boolean
|
||||
onCopy: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-xs'
|
||||
onClick={props.onCopy}
|
||||
title={t('Copy to clipboard')}
|
||||
aria-label={t('Copy to clipboard')}
|
||||
>
|
||||
{props.copied ? (
|
||||
<Check className='text-success size-3' />
|
||||
) : (
|
||||
<Copy className='size-3' />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoPreview({ url }: { url: string }) {
|
||||
const { t } = useTranslation()
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Failed to load video')}
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => window.open(url, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
<ExternalLink className='size-3' />
|
||||
{t('Open in new tab')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
preload='metadata'
|
||||
onError={() => setHasError(true)}
|
||||
className='bg-background max-h-[420px] w-full rounded-md border'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AudioPreview({ clips }: { clips: AudioClip[] }) {
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{clips.map((clip, idx) => (
|
||||
<AudioClipCard key={clip.clip_id || clip.id || idx} clip={clip} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TaskDetailsDialogProps {
|
||||
log: TaskLog
|
||||
isAdmin: boolean
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function TaskDetailsDialog(props: TaskDetailsDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { log, isAdmin } = props
|
||||
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
|
||||
|
||||
const platformName = getTaskPlatformName(log.platform)
|
||||
const duration = formatDuration(log.submit_time, log.finish_time, 'seconds')
|
||||
const videoUrl = resolveVideoUrl(log)
|
||||
const resultUrl = (log.result_url ?? '').trim()
|
||||
const isResultLink =
|
||||
/^https?:\/\//i.test(resultUrl) || resultUrl.startsWith('/v1/videos/')
|
||||
|
||||
const parsedData = useMemo(() => parseJson(log.data), [log.data])
|
||||
const props_ = useMemo(() => {
|
||||
if (isPlainObject(log.properties)) return log.properties
|
||||
const parsed = parseJson(log.properties)
|
||||
return isPlainObject(parsed) ? parsed : null
|
||||
}, [log.properties])
|
||||
|
||||
const asString = (v: unknown): string | undefined =>
|
||||
typeof v === 'string' && v !== '' ? v : undefined
|
||||
const originModel = asString(props_?.origin_model_name)
|
||||
const upstreamModel = asString(props_?.upstream_model_name)
|
||||
const dataModel = isPlainObject(parsedData)
|
||||
? asString(parsedData.model)
|
||||
: undefined
|
||||
const model = originModel || upstreamModel || dataModel
|
||||
|
||||
const audioClips = useMemo(() => {
|
||||
if (log.platform !== TASK_PLATFORMS.SUNO) return []
|
||||
if (log.status !== TASK_STATUS.SUCCESS) return []
|
||||
if (!Array.isArray(parsedData)) return []
|
||||
return parsedData.filter(
|
||||
(c) =>
|
||||
c && typeof c === 'object' && (c as Record<string, unknown>).audio_url
|
||||
) as AudioClip[]
|
||||
}, [log.platform, log.status, parsedData])
|
||||
|
||||
const upstreamEntries = useMemo(
|
||||
() => (isPlainObject(parsedData) ? flattenEntries(parsedData) : []),
|
||||
[parsedData]
|
||||
)
|
||||
const upstreamRaw = useMemo(() => {
|
||||
if (isPlainObject(parsedData) || parsedData == null) return ''
|
||||
try {
|
||||
return JSON.stringify(parsedData, null, 2)
|
||||
} catch {
|
||||
return String(parsedData)
|
||||
}
|
||||
}, [parsedData])
|
||||
|
||||
const rawJson = useMemo(() => {
|
||||
try {
|
||||
return JSON.stringify(log, null, 2)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}, [log])
|
||||
|
||||
const hasFailReason = !!log.fail_reason && log.fail_reason.trim() !== ''
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
title={
|
||||
<>
|
||||
{t('Task Details')}
|
||||
<StatusBadge
|
||||
variant={taskStatusMapper.getVariant(log.status)}
|
||||
size='sm'
|
||||
>
|
||||
{t(
|
||||
taskStatusMapper.getLabel(log.status, log.status || 'Submitting')
|
||||
)}
|
||||
</StatusBadge>
|
||||
</>
|
||||
}
|
||||
description={t('View the complete details for this task')}
|
||||
contentClassName='min-w-0 overflow-hidden max-sm:max-h-[calc(100dvh-1.5rem)] max-sm:w-[calc(100vw-1.5rem)] max-sm:max-w-[calc(100vw-1.5rem)] max-sm:p-4 sm:max-w-lg'
|
||||
headerClassName='max-sm:gap-1'
|
||||
titleClassName='flex items-center gap-2 text-base'
|
||||
descriptionClassName='sr-only'
|
||||
contentHeight='min(78dvh, 760px)'
|
||||
bodyClassName='pr-2 sm:pr-4'
|
||||
>
|
||||
<div className='w-full max-w-full min-w-0 space-y-3 overflow-x-hidden py-1'>
|
||||
{/* Overview */}
|
||||
<DetailSection label={t('Overview')}>
|
||||
{log.task_id && (
|
||||
<DetailRow
|
||||
label={t('Task ID')}
|
||||
value={
|
||||
<span className='flex items-start gap-1'>
|
||||
<span className='min-w-0 break-all'>{log.task_id}</span>
|
||||
<button
|
||||
type='button'
|
||||
className='text-muted-foreground hover:text-foreground mt-0.5 shrink-0'
|
||||
onClick={() => copyToClipboard(log.task_id)}
|
||||
title={t('Copy to clipboard')}
|
||||
aria-label={t('Copy to clipboard')}
|
||||
>
|
||||
{copiedText === log.task_id ? (
|
||||
<Check className='text-success size-3' />
|
||||
) : (
|
||||
<Copy className='size-3' />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
{log.id > 0 && (
|
||||
<DetailRow label={t('Internal ID')} value={String(log.id)} mono />
|
||||
)}
|
||||
<DetailRow label={t('Platform')} value={platformName} />
|
||||
<DetailRow
|
||||
label={t('Action')}
|
||||
value={t(taskActionMapper.getLabel(log.action, log.action))}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('Status')}
|
||||
value={
|
||||
<StatusBadge
|
||||
variant={taskStatusMapper.getVariant(log.status)}
|
||||
size='sm'
|
||||
>
|
||||
{t(
|
||||
taskStatusMapper.getLabel(
|
||||
log.status,
|
||||
log.status || 'Submitting'
|
||||
)
|
||||
)}
|
||||
</StatusBadge>
|
||||
}
|
||||
/>
|
||||
{log.progress && (
|
||||
<DetailRow
|
||||
label={t('Progress')}
|
||||
value={
|
||||
<StatusBadge variant='neutral' size='sm' className='font-mono'>
|
||||
{log.progress}
|
||||
</StatusBadge>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{model && <DetailRow label={t('Model')} value={model} mono />}
|
||||
</DetailSection>
|
||||
|
||||
{/* Timing */}
|
||||
<DetailSection label={t('Timing')}>
|
||||
{log.created_at ? (
|
||||
<DetailRow
|
||||
label={t('Created At')}
|
||||
value={formatTimestampToDate(log.created_at, 'seconds')}
|
||||
mono
|
||||
/>
|
||||
) : null}
|
||||
{log.updated_at ? (
|
||||
<DetailRow
|
||||
label={t('Updated At')}
|
||||
value={formatTimestampToDate(log.updated_at, 'seconds')}
|
||||
mono
|
||||
/>
|
||||
) : null}
|
||||
<DetailRow
|
||||
label={t('Submit Time')}
|
||||
value={formatTimestampToDate(log.submit_time, 'seconds')}
|
||||
mono
|
||||
/>
|
||||
{log.start_time ? (
|
||||
<DetailRow
|
||||
label={t('Start Time')}
|
||||
value={formatTimestampToDate(log.start_time, 'seconds')}
|
||||
mono
|
||||
/>
|
||||
) : null}
|
||||
{log.finish_time ? (
|
||||
<DetailRow
|
||||
label={t('Finish Time')}
|
||||
value={formatTimestampToDate(log.finish_time, 'seconds')}
|
||||
mono
|
||||
/>
|
||||
) : null}
|
||||
{duration && (
|
||||
<DetailRow
|
||||
label={t('Duration')}
|
||||
value={
|
||||
<StatusBadge
|
||||
variant={duration.variant}
|
||||
size='sm'
|
||||
className='tabular-nums'
|
||||
>
|
||||
{duration.durationSec.toFixed(1)}s
|
||||
</StatusBadge>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
{/* Billing */}
|
||||
<DetailSection label={t('Billing')}>
|
||||
{typeof log.quota === 'number' && (
|
||||
<DetailRow
|
||||
label={t('Cost')}
|
||||
value={formatLogQuota(log.quota)}
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
{log.group && <DetailRow label={t('Group')} value={log.group} mono />}
|
||||
{isAdmin && log.channel_id > 0 && (
|
||||
<DetailRow label={t('Channel')} value={`#${log.channel_id}`} mono />
|
||||
)}
|
||||
{isAdmin && log.username && (
|
||||
<DetailRow label={t('User')} value={log.username} />
|
||||
)}
|
||||
{isAdmin && log.user_id > 0 && (
|
||||
<DetailRow label={t('User ID')} value={String(log.user_id)} mono />
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
{/* Request properties */}
|
||||
{(originModel || upstreamModel) && (
|
||||
<DetailSection label={t('Request Properties')}>
|
||||
{originModel && (
|
||||
<DetailRow
|
||||
label={t('Original Model Name')}
|
||||
value={originModel}
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
{upstreamModel && (
|
||||
<DetailRow
|
||||
label={t('Upstream Model Name')}
|
||||
value={upstreamModel}
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{/* Fail reason */}
|
||||
{hasFailReason && (
|
||||
<DetailSection
|
||||
icon={<AlertTriangle className='size-3.5' aria-hidden='true' />}
|
||||
label={t('Fail Reason')}
|
||||
variant='destructive'
|
||||
action={
|
||||
<CopyButton
|
||||
text={log.fail_reason ?? ''}
|
||||
copied={copiedText === log.fail_reason}
|
||||
onCopy={() => copyToClipboard(log.fail_reason ?? '')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<p className='text-destructive text-xs leading-relaxed break-all whitespace-pre-wrap sm:wrap-break-word'>
|
||||
{log.fail_reason}
|
||||
</p>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{(videoUrl || isResultLink || audioClips.length > 0) && (
|
||||
<DetailSection
|
||||
icon={<Video className='size-3.5' aria-hidden='true' />}
|
||||
label={t('Result')}
|
||||
action={
|
||||
isResultLink ? (
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-xs'
|
||||
onClick={() =>
|
||||
window.open(resultUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
title={t('Open in new tab')}
|
||||
aria-label={t('Open in new tab')}
|
||||
>
|
||||
<ExternalLink className='size-3' />
|
||||
</Button>
|
||||
<CopyButton
|
||||
text={resultUrl}
|
||||
copied={copiedText === resultUrl}
|
||||
onCopy={() => copyToClipboard(resultUrl)}
|
||||
/>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{isResultLink && (
|
||||
<DetailRow label={t('Result URL')} value={resultUrl} mono />
|
||||
)}
|
||||
{videoUrl && <VideoPreview url={videoUrl} />}
|
||||
{audioClips.length > 0 && (
|
||||
<div className='flex items-center gap-1.5 pt-1'>
|
||||
<Music
|
||||
className='text-muted-foreground size-3.5'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='text-xs font-medium'>
|
||||
{t('Audio Preview')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{audioClips.length > 0 && <AudioPreview clips={audioClips} />}
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{/* Upstream response (parsed task data) */}
|
||||
{(upstreamEntries.length > 0 || upstreamRaw) && (
|
||||
<DetailSection label={t('Upstream Response')}>
|
||||
{upstreamEntries.length > 0 ? (
|
||||
upstreamEntries.map((entry) => (
|
||||
<DetailRow
|
||||
key={entry.label}
|
||||
label={entry.label}
|
||||
value={entry.value}
|
||||
mono={entry.mono}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<pre className='bg-background/60 max-h-64 min-w-0 overflow-auto rounded border p-2 font-mono text-xs leading-relaxed whitespace-pre'>
|
||||
{upstreamRaw}
|
||||
</pre>
|
||||
)}
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{/* Raw JSON */}
|
||||
{rawJson && (
|
||||
<DetailSection
|
||||
label={t('Raw Data')}
|
||||
action={
|
||||
<CopyButton
|
||||
text={rawJson}
|
||||
copied={copiedText === rawJson}
|
||||
onCopy={() => copyToClipboard(rawJson)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<pre className='bg-background/60 max-h-72 min-w-0 overflow-auto rounded border p-2 font-mono text-xs leading-relaxed whitespace-pre'>
|
||||
{rawJson}
|
||||
</pre>
|
||||
</DetailSection>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -150,9 +150,10 @@ function ModelBadgeContent(
|
||||
return (
|
||||
<StatusBadge
|
||||
variant='neutral'
|
||||
appearance='soft'
|
||||
size='sm'
|
||||
className={cn(
|
||||
'h-5! max-w-none shrink-0 whitespace-nowrap! border-current/20 [&_[data-slot=status-badge-label]]:whitespace-nowrap!',
|
||||
'h-5! max-w-none shrink-0 whitespace-nowrap! [&_[data-slot=status-badge-label]]:whitespace-nowrap!',
|
||||
colorClassName,
|
||||
props.className
|
||||
)}
|
||||
@@ -166,9 +167,10 @@ function ModelBadgeContent(
|
||||
<CopyableStatusBadge
|
||||
value={props.modelName}
|
||||
variant='neutral'
|
||||
appearance='soft'
|
||||
size='sm'
|
||||
className={cn(
|
||||
'h-5! max-w-none shrink-0 whitespace-nowrap! border-current/20 [&_[data-slot=status-badge-label]]:whitespace-nowrap!',
|
||||
'h-5! max-w-none shrink-0 whitespace-nowrap! [&_[data-slot=status-badge-label]]:whitespace-nowrap!',
|
||||
colorClassName,
|
||||
props.className
|
||||
)}
|
||||
|
||||
+36
-25
@@ -97,7 +97,7 @@ function UsageLogCard<TData>(props: { cells: Cell<TData, unknown>[] }) {
|
||||
{(titleCell || badgeCell) && (
|
||||
<div className='flex min-w-0 items-start justify-between gap-3'>
|
||||
{titleCell && (
|
||||
<div className='min-w-0 flex-1 text-[15px] leading-tight font-semibold break-words'>
|
||||
<div className='min-w-0 flex-1 text-base leading-tight font-semibold break-words'>
|
||||
{flexRender(
|
||||
titleCell.column.columnDef.cell,
|
||||
titleCell.getContext()
|
||||
@@ -115,31 +115,42 @@ function UsageLogCard<TData>(props: { cells: Cell<TData, unknown>[] }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rowCells.length > 0 && (
|
||||
<div className='mt-3 space-y-0.5 border-t pt-3'>
|
||||
{rowCells.map((cell) => (
|
||||
<DataTableCardRow
|
||||
key={cell.id}
|
||||
label={getCardLabel(cell)}
|
||||
contentMode={cell.column.columnDef.meta?.contentMode}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataTableCardRow>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{bodyCells.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-3',
|
||||
(titleCell || badgeCell) && 'mt-3'
|
||||
)}
|
||||
>
|
||||
{rowCells.length > 0 && (
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
{rowCells.map((cell) => (
|
||||
<DataTableCardRow
|
||||
key={cell.id}
|
||||
label={getCardLabel(cell)}
|
||||
contentMode={cell.column.columnDef.meta?.contentMode}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataTableCardRow>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wideCells.length > 0 && (
|
||||
<div className='mt-3 space-y-3 border-t pt-3'>
|
||||
{wideCells.map((cell) => (
|
||||
<DataTableCardField
|
||||
key={cell.id}
|
||||
label={getCardLabel(cell)}
|
||||
contentMode={cell.column.columnDef.meta?.contentMode ?? 'full'}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataTableCardField>
|
||||
))}
|
||||
{wideCells.length > 0 && (
|
||||
<div className='bg-muted/30 flex flex-col gap-3 rounded-md px-2.5 py-2'>
|
||||
{wideCells.map((cell) => (
|
||||
<DataTableCardField
|
||||
key={cell.id}
|
||||
label={getCardLabel(cell)}
|
||||
contentMode={
|
||||
cell.column.columnDef.meta?.contentMode ?? 'full'
|
||||
}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataTableCardField>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,8 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
* Status mappers for different log types
|
||||
* Centralized mapper instances for consistent usage across components
|
||||
*/
|
||||
import { CHANNEL_TYPES } from '@/features/channels/constants'
|
||||
|
||||
import {
|
||||
MJ_TASK_TYPE_MAPPINGS,
|
||||
MJ_STATUS_MAPPINGS,
|
||||
@@ -69,3 +71,20 @@ export const taskStatusMapper = createStatusMapper(TASK_STATUS_MAPPINGS)
|
||||
* Task platform mapper
|
||||
*/
|
||||
export const taskPlatformMapper = createStatusMapper(TASK_PLATFORM_MAPPINGS)
|
||||
|
||||
/**
|
||||
* Resolve a task platform value to a human-readable name.
|
||||
*
|
||||
* Video tasks store the platform as a numeric channel type (e.g. "54"), which
|
||||
* maps to a channel name like "DoubaoVideo". Suno-style tasks store a slug
|
||||
* (e.g. "suno"). Falls back to the raw value when nothing matches.
|
||||
*/
|
||||
export function getTaskPlatformName(platform: string): string {
|
||||
if (!platform) return ''
|
||||
const num = Number(platform)
|
||||
if (Number.isInteger(num) && num > 0) {
|
||||
const name = CHANNEL_TYPES[num as keyof typeof CHANNEL_TYPES]
|
||||
if (name) return name
|
||||
}
|
||||
return platform
|
||||
}
|
||||
|
||||
@@ -269,12 +269,19 @@ export interface TaskLog {
|
||||
task_id: string
|
||||
action: string // MUSIC, LYRICS, GENERATE, TEXT_GENERATE, etc.
|
||||
channel_id: number
|
||||
group?: string
|
||||
quota?: number
|
||||
submit_time: number // seconds
|
||||
start_time?: number // seconds
|
||||
finish_time?: number // seconds
|
||||
progress?: string
|
||||
progress_message_en?: string
|
||||
data?: string // JSON string
|
||||
fail_reason?: string
|
||||
// Task result URL (video address, etc.). Backend falls back to fail_reason
|
||||
// when no dedicated result URL is stored, so always validate before use.
|
||||
result_url?: string
|
||||
properties?: unknown
|
||||
status: string // NOT_START, SUBMITTED, IN_PROGRESS, SUCCESS, FAILURE, QUEUED, UNKNOWN
|
||||
other?: string
|
||||
created_at?: number
|
||||
|
||||
@@ -115,9 +115,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
{remark && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<StatusBadge variant='success' />}>
|
||||
<LongText className='max-w-full sm:max-w-[80px]'>
|
||||
{remark}
|
||||
</LongText>
|
||||
{remark}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className='text-xs'>{remark}</p>
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ export function PaymentConfirmDialog({
|
||||
<div className='bg-muted/50 rounded-lg p-3'>
|
||||
<div className='flex items-center justify-between text-sm'>
|
||||
<span className='text-muted-foreground'>{t('You save')}</span>
|
||||
<span className='font-semibold text-green-600'>
|
||||
<span className='text-status-success font-semibold'>
|
||||
{formatCurrency(discountAmount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Vendored
+11
-1
@@ -1428,7 +1428,7 @@
|
||||
"Don't have an account?": "Don't have an account?",
|
||||
"Done": "Done",
|
||||
"Doubao custom API address editing unlocked": "Doubao custom API address editing unlocked",
|
||||
"DoubaoVideo": "DoubaoVideo",
|
||||
"DoubaoVideo": "Doubao Video",
|
||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "Double check the configuration below. Your system will be locked until initialization is complete.",
|
||||
"Downgrade Group": "Downgrade Group",
|
||||
"Downgrade to pre-purchase group": "Downgrade to pre-purchase group",
|
||||
@@ -1845,6 +1845,7 @@
|
||||
"Failed to load setup status": "Failed to load setup status",
|
||||
"Failed to load tag data": "Failed to load tag data",
|
||||
"Failed to load users": "Failed to load users",
|
||||
"Failed to load video": "Failed to load video",
|
||||
"Failed to parse group items": "Failed to parse group items",
|
||||
"Failed to parse JSON file: {{name}}": "Failed to parse JSON file: {{name}}",
|
||||
"Failed to query balance": "Failed to query balance",
|
||||
@@ -3113,6 +3114,7 @@
|
||||
"Order Payment Method": "Order Payment Method",
|
||||
"org-...": "org-...",
|
||||
"Original Model": "Original Model",
|
||||
"Original Model Name": "Original Model Name",
|
||||
"Original value": "Original value",
|
||||
"Other": "Other",
|
||||
"Other channels": "Other channels",
|
||||
@@ -3558,6 +3560,7 @@
|
||||
"Ratio Type": "Ratio Type",
|
||||
"Ratio: {{value}}": "Ratio: {{value}}",
|
||||
"Ratios synced successfully": "Ratios synced successfully",
|
||||
"Raw Data": "Raw Data",
|
||||
"Raw expression": "Raw expression",
|
||||
"Raw JSON": "Raw JSON",
|
||||
"Raw Quota": "Raw Quota",
|
||||
@@ -3715,6 +3718,7 @@
|
||||
"Request Model": "Request Model",
|
||||
"Request Model:": "Request Model:",
|
||||
"Request overrides, routing behavior, and upstream model automation": "Request overrides, routing behavior, and upstream model automation",
|
||||
"Request Properties": "Request Properties",
|
||||
"Request retry": "Request retry",
|
||||
"Request rule pricing": "Request rule pricing",
|
||||
"Request success rate sampled over the last 24 hours": "Request success rate sampled over the last 24 hours",
|
||||
@@ -3791,6 +3795,7 @@
|
||||
"Restore defaults": "Restore defaults",
|
||||
"Restrict user model request frequency (may impact high concurrency performance)": "Restrict user model request frequency (may impact high concurrency performance)",
|
||||
"Result": "Result",
|
||||
"Result URL": "Result URL",
|
||||
"Retain last N days": "Retain last N days",
|
||||
"Retain last N files": "Retain last N files",
|
||||
"Retention days": "Retention days",
|
||||
@@ -4337,6 +4342,7 @@
|
||||
"Target Path (optional)": "Target Path (optional)",
|
||||
"Target User": "Target User",
|
||||
"Task": "Task",
|
||||
"Task Details": "Task Details",
|
||||
"Task History": "Task History",
|
||||
"Task ID": "Task ID",
|
||||
"Task ID:": "Task ID:",
|
||||
@@ -4734,6 +4740,7 @@
|
||||
"Updated a redemption code": "Updated a redemption code",
|
||||
"Updated a subscription plan": "Updated a subscription plan",
|
||||
"Updated a vendor": "Updated a vendor",
|
||||
"Updated At": "Updated At",
|
||||
"Updated channel {{name}} (ID: {{id}})": "Updated channel {{name}} (ID: {{id}})",
|
||||
"Updated daily": "Updated daily",
|
||||
"Updated successfully": "Updated successfully",
|
||||
@@ -4755,6 +4762,7 @@
|
||||
"Upstream did not return reset credit details.": "Upstream did not return reset credit details.",
|
||||
"Upstream Model Detection Settings": "Upstream Model Detection Settings",
|
||||
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.",
|
||||
"Upstream Model Name": "Upstream Model Name",
|
||||
"Upstream Model Update Check": "Upstream Model Update Check",
|
||||
"Upstream Model Updates": "Upstream Model Updates",
|
||||
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models",
|
||||
@@ -4914,6 +4922,7 @@
|
||||
"Vertex AI service account key must be valid JSON": "Vertex AI service account key must be valid JSON",
|
||||
"Video": "Video",
|
||||
"Video length in seconds": "Video length in seconds",
|
||||
"Video Preview": "Video Preview",
|
||||
"Video Remix": "Video Remix",
|
||||
"Vidu": "Vidu",
|
||||
"View": "View",
|
||||
@@ -4930,6 +4939,7 @@
|
||||
"View Pricing": "View Pricing",
|
||||
"View the complete details for this": "View the complete details for this",
|
||||
"View the complete details for this log entry": "View the complete details for this log entry",
|
||||
"View the complete details for this task": "View the complete details for this task",
|
||||
"View the complete error message and details": "View the complete error message and details",
|
||||
"View the complete prompt and its English translation": "View the complete prompt and its English translation",
|
||||
"View the generated image": "View the generated image",
|
||||
|
||||
Vendored
+11
-1
@@ -1428,7 +1428,7 @@
|
||||
"Don't have an account?": "Vous n'avez pas de compte ?",
|
||||
"Done": "Terminé",
|
||||
"Doubao custom API address editing unlocked": "Édition d'adresse API personnalisée Doubao déverrouillée",
|
||||
"DoubaoVideo": "DoubaoVideo",
|
||||
"DoubaoVideo": "Vidéo Doubao",
|
||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "Vérifiez la configuration ci-dessous. Votre système sera verrouillé jusqu'à ce que l'initialisation soit terminée.",
|
||||
"Downgrade Group": "Groupe de rétrogradation",
|
||||
"Downgrade to pre-purchase group": "Rétrograder vers le groupe d'avant l'achat",
|
||||
@@ -1845,6 +1845,7 @@
|
||||
"Failed to load setup status": "Échec du chargement de l'état de configuration",
|
||||
"Failed to load tag data": "Échec du chargement des données de tags",
|
||||
"Failed to load users": "Échec du chargement des utilisateurs",
|
||||
"Failed to load video": "Échec du chargement de la vidéo",
|
||||
"Failed to parse group items": "Échec de l'analyse des éléments de groupe",
|
||||
"Failed to parse JSON file: {{name}}": "Échec de l'analyse du fichier JSON : {{name}}",
|
||||
"Failed to query balance": "Échec de la requête de solde",
|
||||
@@ -3113,6 +3114,7 @@
|
||||
"Order Payment Method": "Moyen de paiement (commande)",
|
||||
"org-...": "org-...",
|
||||
"Original Model": "Modèle Original",
|
||||
"Original Model Name": "Nom du modèle d'origine",
|
||||
"Original value": "Valeur d'origine",
|
||||
"Other": "Autre",
|
||||
"Other channels": "Autres canaux",
|
||||
@@ -3558,6 +3560,7 @@
|
||||
"Ratio Type": "Type de ratio",
|
||||
"Ratio: {{value}}": "Ratio : {{value}}",
|
||||
"Ratios synced successfully": "Ratios synchronisés avec succès",
|
||||
"Raw Data": "Données brutes",
|
||||
"Raw expression": "Expression brute",
|
||||
"Raw JSON": "JSON brut",
|
||||
"Raw Quota": "Quota brut",
|
||||
@@ -3715,6 +3718,7 @@
|
||||
"Request Model": "Modèle demandé",
|
||||
"Request Model:": "Modèle demandé :",
|
||||
"Request overrides, routing behavior, and upstream model automation": "Surcharges de requête, comportement de routage et automatisation des modèles amont",
|
||||
"Request Properties": "Propriétés de la requête",
|
||||
"Request retry": "Relance des requêtes",
|
||||
"Request rule pricing": "Règles de tarification de requête",
|
||||
"Request success rate sampled over the last 24 hours": "Taux de réussite des requêtes échantillonné sur les dernières 24 heures",
|
||||
@@ -3791,6 +3795,7 @@
|
||||
"Restore defaults": "Restaurer les paramètres par défaut",
|
||||
"Restrict user model request frequency (may impact high concurrency performance)": "Restreindre la fréquence des requêtes du modèle utilisateur (peut impacter les performances en cas de forte concurrence)",
|
||||
"Result": "Résultat",
|
||||
"Result URL": "URL du résultat",
|
||||
"Retain last N days": "Conserver les N derniers jours",
|
||||
"Retain last N files": "Conserver les N derniers fichiers",
|
||||
"Retention days": "Jours de rétention",
|
||||
@@ -4337,6 +4342,7 @@
|
||||
"Target Path (optional)": "Chemin cible (optionnel)",
|
||||
"Target User": "Utilisateur cible",
|
||||
"Task": "Tâche",
|
||||
"Task Details": "Détails de la tâche",
|
||||
"Task History": "Historique des tâches",
|
||||
"Task ID": "ID de la tâche",
|
||||
"Task ID:": "ID de tâche :",
|
||||
@@ -4734,6 +4740,7 @@
|
||||
"Updated a redemption code": "Code de réduction mis à jour",
|
||||
"Updated a subscription plan": "Forfait d'abonnement mis à jour",
|
||||
"Updated a vendor": "Fournisseur mis à jour",
|
||||
"Updated At": "Mis à jour le",
|
||||
"Updated channel {{name}} (ID: {{id}})": "Canal {{name}} mis à jour (ID : {{id}})",
|
||||
"Updated daily": "Mis à jour quotidiennement",
|
||||
"Updated successfully": "Mise à jour réussie",
|
||||
@@ -4755,6 +4762,7 @@
|
||||
"Upstream did not return reset credit details.": "L'amont n'a renvoyé aucun détail de crédit de réinitialisation.",
|
||||
"Upstream Model Detection Settings": "Paramètres de détection des modèles en amont",
|
||||
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "Tâche de détection des modèles en amont démarrée. Suivez la progression dans Infos système, puis actualisez pour examiner les mises à jour en attente.",
|
||||
"Upstream Model Name": "Nom du modèle amont",
|
||||
"Upstream Model Update Check": "Vérification des mises à jour des modèles en amont",
|
||||
"Upstream Model Updates": "Mises à jour des modèles en amont",
|
||||
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "Mises à jour des modèles en amont appliquées : {{added}} ajoutés, {{removed}} supprimés, {{ignored}} ignorés cette fois, {{totalIgnored}} modèles ignorés au total",
|
||||
@@ -4914,6 +4922,7 @@
|
||||
"Vertex AI service account key must be valid JSON": "La clé de compte de service Vertex AI doit être un JSON valide",
|
||||
"Video": "Vidéo",
|
||||
"Video length in seconds": "Durée de la vidéo en secondes",
|
||||
"Video Preview": "Aperçu vidéo",
|
||||
"Video Remix": "Remix vidéo",
|
||||
"Vidu": "Vidu",
|
||||
"View": "Afficher",
|
||||
@@ -4930,6 +4939,7 @@
|
||||
"View Pricing": "Voir les tarifs",
|
||||
"View the complete details for this": "Voir les détails complets de ce",
|
||||
"View the complete details for this log entry": "Voir les détails complets de cette entrée de journal",
|
||||
"View the complete details for this task": "Voir les détails complets de cette tâche",
|
||||
"View the complete error message and details": "Voir le message d'erreur et les détails complets",
|
||||
"View the complete prompt and its English translation": "Voir l'invite complète et sa traduction anglaise",
|
||||
"View the generated image": "Voir l'image générée",
|
||||
|
||||
Vendored
+11
-1
@@ -1428,7 +1428,7 @@
|
||||
"Don't have an account?": "アカウントをお持ちでないですか?",
|
||||
"Done": "完了",
|
||||
"Doubao custom API address editing unlocked": "豆包カスタムAPI アドレス編集がアンロックされました",
|
||||
"DoubaoVideo": "DoubaoVideo",
|
||||
"DoubaoVideo": "Doubao動画",
|
||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "下記の設定を再確認してください。初期化が完了するまでシステムはロックされます。",
|
||||
"Downgrade Group": "ダウングレードグループ",
|
||||
"Downgrade to pre-purchase group": "購入前のグループにダウングレード",
|
||||
@@ -1845,6 +1845,7 @@
|
||||
"Failed to load setup status": "セットアップ状態の読み込みに失敗しました",
|
||||
"Failed to load tag data": "タグデータの読み込みに失敗しました",
|
||||
"Failed to load users": "ユーザーの読み込みに失敗しました",
|
||||
"Failed to load video": "動画の読み込みに失敗しました",
|
||||
"Failed to parse group items": "グループアイテムの解析に失敗しました",
|
||||
"Failed to parse JSON file: {{name}}": "JSONファイルの解析に失敗しました: __ PH_0 __",
|
||||
"Failed to query balance": "残高の取得に失敗しました",
|
||||
@@ -3113,6 +3114,7 @@
|
||||
"Order Payment Method": "注文の支払い方法",
|
||||
"org-...": "org-...",
|
||||
"Original Model": "オリジナルモデル",
|
||||
"Original Model Name": "元のモデル名",
|
||||
"Original value": "元の値",
|
||||
"Other": "その他",
|
||||
"Other channels": "その他のチャネル",
|
||||
@@ -3558,6 +3560,7 @@
|
||||
"Ratio Type": "比率タイプ",
|
||||
"Ratio: {{value}}": "倍率:{{value}}",
|
||||
"Ratios synced successfully": "比率が正常に同期されました",
|
||||
"Raw Data": "生データ",
|
||||
"Raw expression": "元の式",
|
||||
"Raw JSON": "生 JSON",
|
||||
"Raw Quota": "元のクォータ",
|
||||
@@ -3715,6 +3718,7 @@
|
||||
"Request Model": "リクエストモデル",
|
||||
"Request Model:": "リクエストモデル:",
|
||||
"Request overrides, routing behavior, and upstream model automation": "リクエスト上書き、ルーティング動作、上流モデル自動化",
|
||||
"Request Properties": "リクエスト属性",
|
||||
"Request retry": "リクエスト再試行",
|
||||
"Request rule pricing": "リクエストルールの課金",
|
||||
"Request success rate sampled over the last 24 hours": "過去 24 時間にサンプリングされたリクエスト成功率",
|
||||
@@ -3791,6 +3795,7 @@
|
||||
"Restore defaults": "既定に戻す",
|
||||
"Restrict user model request frequency (may impact high concurrency performance)": "ユーザーモデルのリクエスト頻度を制限する(高並行性パフォーマンスに影響を与える可能性があります)",
|
||||
"Result": "結果",
|
||||
"Result URL": "結果 URL",
|
||||
"Retain last N days": "最新N日間を保持",
|
||||
"Retain last N files": "最新N個のファイルを保持",
|
||||
"Retention days": "保持日数",
|
||||
@@ -4337,6 +4342,7 @@
|
||||
"Target Path (optional)": "ターゲットパス(任意)",
|
||||
"Target User": "対象ユーザー",
|
||||
"Task": "タスク",
|
||||
"Task Details": "タスクの詳細",
|
||||
"Task History": "タスク履歴",
|
||||
"Task ID": "タスクID",
|
||||
"Task ID:": "タスクID:",
|
||||
@@ -4734,6 +4740,7 @@
|
||||
"Updated a redemption code": "引換コードを更新しました",
|
||||
"Updated a subscription plan": "サブスクリプションプランを更新しました",
|
||||
"Updated a vendor": "ベンダーを更新しました",
|
||||
"Updated At": "更新日時",
|
||||
"Updated channel {{name}} (ID: {{id}})": "チャネル {{name}} を更新しました(ID: {{id}})",
|
||||
"Updated daily": "毎日更新",
|
||||
"Updated successfully": "正常に更新されました",
|
||||
@@ -4755,6 +4762,7 @@
|
||||
"Upstream did not return reset credit details.": "上流からリセット回数の詳細が返されませんでした。",
|
||||
"Upstream Model Detection Settings": "アップストリームモデル検出設定",
|
||||
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "上流モデル検出タスクを開始しました。システム情報で進捗を確認し、完了後に更新してステージングされた変更をご確認ください。",
|
||||
"Upstream Model Name": "アップストリームのモデル名",
|
||||
"Upstream Model Update Check": "アップストリームモデル更新チェック",
|
||||
"Upstream Model Updates": "上流モデルの更新",
|
||||
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "上流モデル更新を処理しました:{{added}} 個追加、{{removed}} 個削除、今回 {{ignored}} 個無視、合計 {{totalIgnored}} 個の無視モデル",
|
||||
@@ -4914,6 +4922,7 @@
|
||||
"Vertex AI service account key must be valid JSON": "Vertex AI サービスアカウントキーは有効な JSON である必要があります",
|
||||
"Video": "動画",
|
||||
"Video length in seconds": "動画の長さ(秒)",
|
||||
"Video Preview": "動画プレビュー",
|
||||
"Video Remix": "動画 Remix",
|
||||
"Vidu": "Vidu",
|
||||
"View": "表示",
|
||||
@@ -4930,6 +4939,7 @@
|
||||
"View Pricing": "価格を見る",
|
||||
"View the complete details for this": "この",
|
||||
"View the complete details for this log entry": "このログエントリの完全な詳細を表示",
|
||||
"View the complete details for this task": "このタスクの詳細を表示",
|
||||
"View the complete error message and details": "エラーメッセージと詳細を表示",
|
||||
"View the complete prompt and its English translation": "プロンプト全文と英語訳を表示",
|
||||
"View the generated image": "生成された画像を表示",
|
||||
|
||||
Vendored
+11
-1
@@ -1428,7 +1428,7 @@
|
||||
"Don't have an account?": "У вас нет аккаунта?",
|
||||
"Done": "Готово",
|
||||
"Doubao custom API address editing unlocked": "Редактирование пользовательского адреса API Doubao разблокировано",
|
||||
"DoubaoVideo": "DoubaoVideo",
|
||||
"DoubaoVideo": "Видео Doubao",
|
||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "Дважды проверьте конфигурацию ниже. Ваша система будет заблокирована до завершения инициализации.",
|
||||
"Downgrade Group": "Понизить группу",
|
||||
"Downgrade to pre-purchase group": "Понизить до группы до покупки",
|
||||
@@ -1845,6 +1845,7 @@
|
||||
"Failed to load setup status": "Не удалось загрузить статус настройки",
|
||||
"Failed to load tag data": "Не удалось загрузить данные тегов",
|
||||
"Failed to load users": "Не удалось загрузить пользователей",
|
||||
"Failed to load video": "Не удалось загрузить видео",
|
||||
"Failed to parse group items": "Не удалось разобрать элементы группы",
|
||||
"Failed to parse JSON file: {{name}}": "Не удалось проанализировать файл JSON: {{name}}",
|
||||
"Failed to query balance": "Не удалось запросить баланс",
|
||||
@@ -3113,6 +3114,7 @@
|
||||
"Order Payment Method": "Способ оплаты (заказа)",
|
||||
"org-...": "орг-...",
|
||||
"Original Model": "Оригинальная модель",
|
||||
"Original Model Name": "Исходное имя модели",
|
||||
"Original value": "Исходное значение",
|
||||
"Other": "Другое",
|
||||
"Other channels": "Другие каналы",
|
||||
@@ -3558,6 +3560,7 @@
|
||||
"Ratio Type": "Тип соотношения",
|
||||
"Ratio: {{value}}": "Коэффициент: {{value}}",
|
||||
"Ratios synced successfully": "Соотношения успешно синхронизированы",
|
||||
"Raw Data": "Необработанные данные",
|
||||
"Raw expression": "Исходное выражение",
|
||||
"Raw JSON": "Сырой JSON",
|
||||
"Raw Quota": "Исходная квота",
|
||||
@@ -3715,6 +3718,7 @@
|
||||
"Request Model": "Запрошенная модель",
|
||||
"Request Model:": "Модель запроса:",
|
||||
"Request overrides, routing behavior, and upstream model automation": "Переопределения запросов, маршрутизация и автоматизация upstream-моделей",
|
||||
"Request Properties": "Свойства запроса",
|
||||
"Request retry": "Повтор запросов",
|
||||
"Request rule pricing": "Правила ценообразования по запросу",
|
||||
"Request success rate sampled over the last 24 hours": "Доля успешных запросов по выборкам за последние 24 часа",
|
||||
@@ -3791,6 +3795,7 @@
|
||||
"Restore defaults": "Сбросить к значениям по умолчанию",
|
||||
"Restrict user model request frequency (may impact high concurrency performance)": "Ограничить частоту запросов пользовательских моделей (может повлиять на производительность при высокой конкуренции)",
|
||||
"Result": "Результат",
|
||||
"Result URL": "URL результата",
|
||||
"Retain last N days": "Хранить последние N дней",
|
||||
"Retain last N files": "Хранить последние N файлов",
|
||||
"Retention days": "Дней хранения",
|
||||
@@ -4337,6 +4342,7 @@
|
||||
"Target Path (optional)": "Целевой путь (необязательно)",
|
||||
"Target User": "Целевой пользователь",
|
||||
"Task": "Задача",
|
||||
"Task Details": "Сведения о задаче",
|
||||
"Task History": "История задач",
|
||||
"Task ID": "ID задачи",
|
||||
"Task ID:": "ID задачи:",
|
||||
@@ -4734,6 +4740,7 @@
|
||||
"Updated a redemption code": "Код погашения обновлён",
|
||||
"Updated a subscription plan": "Обновлён тарифный план подписки",
|
||||
"Updated a vendor": "Обновлён поставщик",
|
||||
"Updated At": "Обновлено",
|
||||
"Updated channel {{name}} (ID: {{id}})": "Обновлён канал {{name}} (ID: {{id}})",
|
||||
"Updated daily": "Обновляется ежедневно",
|
||||
"Updated successfully": "Обновлено успешно",
|
||||
@@ -4755,6 +4762,7 @@
|
||||
"Upstream did not return reset credit details.": "Вышестоящий сервис не вернул сведения о сбросах лимита.",
|
||||
"Upstream Model Detection Settings": "Настройки обнаружения моделей провайдера",
|
||||
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "Задача обнаружения моделей вышестоящего источника запущена. Следите за ходом в разделе «Информация о системе», затем обновите, чтобы просмотреть подготовленные изменения.",
|
||||
"Upstream Model Name": "Имя вышестоящей модели",
|
||||
"Upstream Model Update Check": "Проверка обновлений моделей провайдера",
|
||||
"Upstream Model Updates": "Обновления моделей источника",
|
||||
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "Обновления моделей применены: {{added}} добавлено, {{removed}} удалено, {{ignored}} проигнорировано, всего {{totalIgnored}} проигнорированных моделей",
|
||||
@@ -4914,6 +4922,7 @@
|
||||
"Vertex AI service account key must be valid JSON": "Ключ сервисного аккаунта Vertex AI должен быть допустимым JSON",
|
||||
"Video": "Видео",
|
||||
"Video length in seconds": "Длительность видео в секундах",
|
||||
"Video Preview": "Предпросмотр видео",
|
||||
"Video Remix": "Ремикс видео",
|
||||
"Vidu": "Vidu",
|
||||
"View": "Просмотр",
|
||||
@@ -4930,6 +4939,7 @@
|
||||
"View Pricing": "Посмотреть цены",
|
||||
"View the complete details for this": "Просмотр полных деталей этой",
|
||||
"View the complete details for this log entry": "Просмотр полной информации об этой записи журнала",
|
||||
"View the complete details for this task": "Посмотреть полные сведения о задаче",
|
||||
"View the complete error message and details": "Просмотр полного сообщения об ошибке и деталей",
|
||||
"View the complete prompt and its English translation": "Просмотр полного промпта и его перевода на английский",
|
||||
"View the generated image": "Просмотр сгенерированного изображения",
|
||||
|
||||
Vendored
+11
-1
@@ -1428,7 +1428,7 @@
|
||||
"Don't have an account?": "Chưa có tài khoản?",
|
||||
"Done": "Xong",
|
||||
"Doubao custom API address editing unlocked": "Đã mở khóa chỉnh sửa địa chỉ API tùy chỉnh Doubao",
|
||||
"DoubaoVideo": "DoubaoVideo",
|
||||
"DoubaoVideo": "Video Doubao",
|
||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "Kiểm tra kỹ lại cấu hình bên dưới. Hệ thống của bạn sẽ bị khóa cho đến khi quá trình khởi tạo hoàn tất.",
|
||||
"Downgrade Group": "Nhóm hạ cấp",
|
||||
"Downgrade to pre-purchase group": "Hạ xuống nhóm trước khi mua",
|
||||
@@ -1845,6 +1845,7 @@
|
||||
"Failed to load setup status": "Không thể tải trạng thái thiết lập",
|
||||
"Failed to load tag data": "Không thể tải dữ liệu thẻ",
|
||||
"Failed to load users": "Không thể tải người dùng",
|
||||
"Failed to load video": "Không tải được video",
|
||||
"Failed to parse group items": "Thất bại khi phân tích các mục nhóm",
|
||||
"Failed to parse JSON file: {{name}}": "Không thể phân tích cú pháp tệp JSON: {{name}}",
|
||||
"Failed to query balance": "Không thể truy vấn số dư",
|
||||
@@ -3113,6 +3114,7 @@
|
||||
"Order Payment Method": "Phương thức thanh toán đơn hàng",
|
||||
"org-...": "org-...",
|
||||
"Original Model": "Nguyên mẫu",
|
||||
"Original Model Name": "Tên mô hình gốc",
|
||||
"Original value": "Giá trị gốc",
|
||||
"Other": "Khác",
|
||||
"Other channels": "Kênh khác",
|
||||
@@ -3558,6 +3560,7 @@
|
||||
"Ratio Type": "Rate type",
|
||||
"Ratio: {{value}}": "Tỷ lệ: {{value}}",
|
||||
"Ratios synced successfully": "Tỷ lệ đã đồng bộ thành công",
|
||||
"Raw Data": "Dữ liệu thô",
|
||||
"Raw expression": "Biểu thức gốc",
|
||||
"Raw JSON": "JSON thô",
|
||||
"Raw Quota": "Hạn mức gốc",
|
||||
@@ -3715,6 +3718,7 @@
|
||||
"Request Model": "Mô hình yêu cầu",
|
||||
"Request Model:": "Mô hình yêu cầu:",
|
||||
"Request overrides, routing behavior, and upstream model automation": "Ghi đè yêu cầu, hành vi định tuyến và tự động hóa mô hình upstream",
|
||||
"Request Properties": "Thuộc tính yêu cầu",
|
||||
"Request retry": "Thử lại yêu cầu",
|
||||
"Request rule pricing": "Quy tắc tính giá theo request",
|
||||
"Request success rate sampled over the last 24 hours": "Tỷ lệ yêu cầu thành công được lấy mẫu trong 24 giờ qua",
|
||||
@@ -3791,6 +3795,7 @@
|
||||
"Restore defaults": "Khôi phục mặc định",
|
||||
"Restrict user model request frequency (may impact high concurrency performance)": "Hạn chế tần suất yêu cầu mô hình người dùng (có thể ảnh hưởng đến hiệu suất khi có độ đồng thời cao)",
|
||||
"Result": "Kết quả",
|
||||
"Result URL": "URL kết quả",
|
||||
"Retain last N days": "Giữ lại N ngày gần nhất",
|
||||
"Retain last N files": "Giữ lại N tệp gần nhất",
|
||||
"Retention days": "Số ngày lưu giữ",
|
||||
@@ -4337,6 +4342,7 @@
|
||||
"Target Path (optional)": "Đường dẫn đích (tùy chọn)",
|
||||
"Target User": "Người dùng mục tiêu",
|
||||
"Task": "Nhiệm vụ",
|
||||
"Task Details": "Chi tiết tác vụ",
|
||||
"Task History": "Lịch sử tác vụ",
|
||||
"Task ID": "Mã nhiệm vụ",
|
||||
"Task ID:": "ID nhiệm vụ:",
|
||||
@@ -4734,6 +4740,7 @@
|
||||
"Updated a redemption code": "Đã cập nhật một mã đổi thưởng",
|
||||
"Updated a subscription plan": "Đã cập nhật một gói đăng ký",
|
||||
"Updated a vendor": "Đã cập nhật một nhà cung cấp",
|
||||
"Updated At": "Cập nhật lúc",
|
||||
"Updated channel {{name}} (ID: {{id}})": "Đã cập nhật kênh {{name}} (ID: {{id}})",
|
||||
"Updated daily": "Cập nhật hàng ngày",
|
||||
"Updated successfully": "Cập nhật thành công",
|
||||
@@ -4755,6 +4762,7 @@
|
||||
"Upstream did not return reset credit details.": "Upstream không trả về chi tiết lượt đặt lại.",
|
||||
"Upstream Model Detection Settings": "Cài đặt phát hiện mô hình nguồn",
|
||||
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "Đã bắt đầu tác vụ phát hiện mô hình thượng nguồn. Theo dõi tiến trình trong Thông tin hệ thống, sau đó làm mới để xem các cập nhật đang chờ.",
|
||||
"Upstream Model Name": "Tên mô hình thượng nguồn",
|
||||
"Upstream Model Update Check": "Kiểm tra cập nhật mô hình nguồn",
|
||||
"Upstream Model Updates": "Cập nhật mô hình upstream",
|
||||
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "Đã áp dụng cập nhật mô hình upstream: {{added}} đã thêm, {{removed}} đã xóa, {{ignored}} bỏ qua lần này, {{totalIgnored}} tổng mô hình đã bỏ qua",
|
||||
@@ -4914,6 +4922,7 @@
|
||||
"Vertex AI service account key must be valid JSON": "Khóa tài khoản dịch vụ Vertex AI phải là JSON hợp lệ",
|
||||
"Video": "Video",
|
||||
"Video length in seconds": "Độ dài video (giây)",
|
||||
"Video Preview": "Xem trước video",
|
||||
"Video Remix": "Remix video",
|
||||
"Vidu": "Vidu",
|
||||
"View": "Xem",
|
||||
@@ -4930,6 +4939,7 @@
|
||||
"View Pricing": "View price",
|
||||
"View the complete details for this": "Xem chi tiết đầy đủ của",
|
||||
"View the complete details for this log entry": "Xem chi tiết đầy đủ cho mục nhật ký này",
|
||||
"View the complete details for this task": "Xem chi tiết đầy đủ của tác vụ này",
|
||||
"View the complete error message and details": "Xem toàn bộ thông báo lỗi và chi tiết",
|
||||
"View the complete prompt and its English translation": "Xem toàn bộ lời nhắc và bản dịch tiếng Anh",
|
||||
"View the generated image": "Xem ảnh đã tạo",
|
||||
|
||||
+12
-2
@@ -1428,7 +1428,7 @@
|
||||
"Don't have an account?": "沒有賬號?",
|
||||
"Done": "完成",
|
||||
"Doubao custom API address editing unlocked": "已解鎖豆包自訂 API 地址編輯",
|
||||
"DoubaoVideo": "DoubaoVideo",
|
||||
"DoubaoVideo": "豆包影片",
|
||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "仔細檢查以下設定。您的系統將在初始化完成前保持鎖定狀態。",
|
||||
"Downgrade Group": "降級分組",
|
||||
"Downgrade to pre-purchase group": "降級到購買前分組",
|
||||
@@ -1845,6 +1845,7 @@
|
||||
"Failed to load setup status": "無法載入設定狀態",
|
||||
"Failed to load tag data": "載入標籤數據失敗",
|
||||
"Failed to load users": "載入用戶失敗",
|
||||
"Failed to load video": "影片載入失敗",
|
||||
"Failed to parse group items": "解析組項目失敗",
|
||||
"Failed to parse JSON file: {{name}}": "解析 JSON 檔案失敗:{{name}}",
|
||||
"Failed to query balance": "查詢餘額失敗",
|
||||
@@ -2003,7 +2004,7 @@
|
||||
"footer.columns.related.links.oneApi": "One API",
|
||||
"footer.columns.related.title": "相關項目",
|
||||
"footer.defaultCopyright": "版權所有。",
|
||||
"footer.newapi.projectAttributionSuffix": "版權所有,由項目貢獻者設計與開發。",
|
||||
"footer.new\u0061pi.projectAttributionSuffix": "版權所有,由項目貢獻者設計與開發。",
|
||||
"For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "對於 2025 年 5 月 10 日之後新增的渠道,在部署時無需從模型名稱中移除 \".\"",
|
||||
"For private deployments, format: https://fastgpt.run/api/openapi": "對於私有部署,格式為:https://fastgpt.run/api/openapi",
|
||||
"Force a syntactically valid JSON response": "強制返回語法合法的 JSON",
|
||||
@@ -3113,6 +3114,7 @@
|
||||
"Order Payment Method": "訂單支付方式",
|
||||
"org-...": "org-...",
|
||||
"Original Model": "原始模型",
|
||||
"Original Model Name": "原始模型名稱",
|
||||
"Original value": "原始值",
|
||||
"Other": "其他",
|
||||
"Other channels": "其他渠道",
|
||||
@@ -3558,6 +3560,7 @@
|
||||
"Ratio Type": "比率類型",
|
||||
"Ratio: {{value}}": "倍率:{{value}}",
|
||||
"Ratios synced successfully": "比率同步成功",
|
||||
"Raw Data": "原始資料",
|
||||
"Raw expression": "原始表達式",
|
||||
"Raw JSON": "原始 JSON",
|
||||
"Raw Quota": "原生額度",
|
||||
@@ -3715,6 +3718,7 @@
|
||||
"Request Model": "請求模型",
|
||||
"Request Model:": "請求模型:",
|
||||
"Request overrides, routing behavior, and upstream model automation": "請求覆蓋、路由行為和上游模型自動化",
|
||||
"Request Properties": "請求屬性",
|
||||
"Request retry": "請求重試",
|
||||
"Request rule pricing": "請求規則收費",
|
||||
"Request success rate sampled over the last 24 hours": "最近 24 小時按時間桶採樣的請求成功率",
|
||||
@@ -3791,6 +3795,7 @@
|
||||
"Restore defaults": "恢復預設",
|
||||
"Restrict user model request frequency (may impact high concurrency performance)": "限制用戶模型請求頻率(可能會影響高並發效能)",
|
||||
"Result": "結果",
|
||||
"Result URL": "結果 URL",
|
||||
"Retain last N days": "保留最近N天",
|
||||
"Retain last N files": "保留最近 N 個檔案",
|
||||
"Retention days": "保留天數",
|
||||
@@ -4337,6 +4342,7 @@
|
||||
"Target Path (optional)": "目標路徑(可選)",
|
||||
"Target User": "目標用戶",
|
||||
"Task": "任務",
|
||||
"Task Details": "任務詳情",
|
||||
"Task History": "歷史任務",
|
||||
"Task ID": "任務 ID",
|
||||
"Task ID:": "任務 ID:",
|
||||
@@ -4734,6 +4740,7 @@
|
||||
"Updated a redemption code": "更新了一個兌換碼",
|
||||
"Updated a subscription plan": "更新了一個訂閱計劃",
|
||||
"Updated a vendor": "更新了一個供應商",
|
||||
"Updated At": "更新時間",
|
||||
"Updated channel {{name}} (ID: {{id}})": "更新渠道 {{name}}(ID: {{id}})",
|
||||
"Updated daily": "每日更新",
|
||||
"Updated successfully": "更新成功",
|
||||
@@ -4755,6 +4762,7 @@
|
||||
"Upstream did not return reset credit details.": "上游未返回重置次數詳情。",
|
||||
"Upstream Model Detection Settings": "偵測上游模型設定",
|
||||
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "上游模型檢測任務已開始。可在「系統資訊」中查看進度,完成後重新整理以查看待處理的更新。",
|
||||
"Upstream Model Name": "上游模型名稱",
|
||||
"Upstream Model Update Check": "上游模型更新檢查",
|
||||
"Upstream Model Updates": "上游模型更新",
|
||||
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "已處理上游模型更新:加入 {{added}} 個,刪除 {{removed}} 個,本次忽略 {{ignored}} 個,目前已忽略模型 {{totalIgnored}} 個",
|
||||
@@ -4914,6 +4922,7 @@
|
||||
"Vertex AI service account key must be valid JSON": "Vertex AI 服務賬號金鑰必須是有效 JSON",
|
||||
"Video": "影片",
|
||||
"Video length in seconds": "影片時長(秒)",
|
||||
"Video Preview": "影片預覽",
|
||||
"Video Remix": "影片 Remix",
|
||||
"Vidu": "Vidu",
|
||||
"View": "查看",
|
||||
@@ -4930,6 +4939,7 @@
|
||||
"View Pricing": "查看定價",
|
||||
"View the complete details for this": "查看此條",
|
||||
"View the complete details for this log entry": "查看此日誌條目的完整詳情",
|
||||
"View the complete details for this task": "檢視該任務的完整詳情",
|
||||
"View the complete error message and details": "查看完整錯誤資訊與詳情",
|
||||
"View the complete prompt and its English translation": "查看完整提示詞及其英文翻譯",
|
||||
"View the generated image": "查看生成的圖片",
|
||||
|
||||
Vendored
+11
-1
@@ -1428,7 +1428,7 @@
|
||||
"Don't have an account?": "没有账号?",
|
||||
"Done": "完成",
|
||||
"Doubao custom API address editing unlocked": "已解锁豆包自定义 API 地址编辑",
|
||||
"DoubaoVideo": "DoubaoVideo",
|
||||
"DoubaoVideo": "豆包视频",
|
||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "仔细检查以下配置。您的系统将在初始化完成前保持锁定状态。",
|
||||
"Downgrade Group": "降级分组",
|
||||
"Downgrade to pre-purchase group": "降级到购买前分组",
|
||||
@@ -1845,6 +1845,7 @@
|
||||
"Failed to load setup status": "无法加载设置状态",
|
||||
"Failed to load tag data": "加载标签数据失败",
|
||||
"Failed to load users": "加载用户失败",
|
||||
"Failed to load video": "视频加载失败",
|
||||
"Failed to parse group items": "解析组项目失败",
|
||||
"Failed to parse JSON file: {{name}}": "解析 JSON 文件失败:{{name}}",
|
||||
"Failed to query balance": "查询余额失败",
|
||||
@@ -3113,6 +3114,7 @@
|
||||
"Order Payment Method": "订单支付方式",
|
||||
"org-...": "org-...",
|
||||
"Original Model": "原始模型",
|
||||
"Original Model Name": "原始模型名",
|
||||
"Original value": "原始值",
|
||||
"Other": "其他",
|
||||
"Other channels": "其他渠道",
|
||||
@@ -3558,6 +3560,7 @@
|
||||
"Ratio Type": "比率类型",
|
||||
"Ratio: {{value}}": "倍率:{{value}}",
|
||||
"Ratios synced successfully": "比率同步成功",
|
||||
"Raw Data": "原始数据",
|
||||
"Raw expression": "原始表达式",
|
||||
"Raw JSON": "原始 JSON",
|
||||
"Raw Quota": "原生额度",
|
||||
@@ -3715,6 +3718,7 @@
|
||||
"Request Model": "请求模型",
|
||||
"Request Model:": "请求模型:",
|
||||
"Request overrides, routing behavior, and upstream model automation": "请求覆盖、路由行为和上游模型自动化",
|
||||
"Request Properties": "请求属性",
|
||||
"Request retry": "请求重试",
|
||||
"Request rule pricing": "请求规则计费",
|
||||
"Request success rate sampled over the last 24 hours": "最近 24 小时按时间桶采样的请求成功率",
|
||||
@@ -3791,6 +3795,7 @@
|
||||
"Restore defaults": "恢复默认",
|
||||
"Restrict user model request frequency (may impact high concurrency performance)": "限制用户模型请求频率(可能会影响高并发性能)",
|
||||
"Result": "结果",
|
||||
"Result URL": "结果 URL",
|
||||
"Retain last N days": "保留最近N天",
|
||||
"Retain last N files": "保留最近 N 个文件",
|
||||
"Retention days": "保留天数",
|
||||
@@ -4337,6 +4342,7 @@
|
||||
"Target Path (optional)": "目标路径(可选)",
|
||||
"Target User": "目标用户",
|
||||
"Task": "任务",
|
||||
"Task Details": "任务详情",
|
||||
"Task History": "历史任务",
|
||||
"Task ID": "任务 ID",
|
||||
"Task ID:": "任务 ID:",
|
||||
@@ -4734,6 +4740,7 @@
|
||||
"Updated a redemption code": "更新了一个兑换码",
|
||||
"Updated a subscription plan": "更新了一个订阅计划",
|
||||
"Updated a vendor": "更新了一个供应商",
|
||||
"Updated At": "更新时间",
|
||||
"Updated channel {{name}} (ID: {{id}})": "更新渠道 {{name}}(ID: {{id}})",
|
||||
"Updated daily": "每日更新",
|
||||
"Updated successfully": "更新成功",
|
||||
@@ -4755,6 +4762,7 @@
|
||||
"Upstream did not return reset credit details.": "上游未返回重置次数详情。",
|
||||
"Upstream Model Detection Settings": "检测上游模型设置",
|
||||
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "上游模型检测任务已开始。可在「系统信息」中查看进度,完成后刷新以查看待处理的更新。",
|
||||
"Upstream Model Name": "上游模型名",
|
||||
"Upstream Model Update Check": "上游模型更新检查",
|
||||
"Upstream Model Updates": "上游模型更新",
|
||||
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "已处理上游模型更新:加入 {{added}} 个,删除 {{removed}} 个,本次忽略 {{ignored}} 个,当前已忽略模型 {{totalIgnored}} 个",
|
||||
@@ -4914,6 +4922,7 @@
|
||||
"Vertex AI service account key must be valid JSON": "Vertex AI 服务账号密钥必须是有效 JSON",
|
||||
"Video": "视频",
|
||||
"Video length in seconds": "视频时长(秒)",
|
||||
"Video Preview": "视频预览",
|
||||
"Video Remix": "视频 Remix",
|
||||
"Vidu": "Vidu",
|
||||
"View": "查看",
|
||||
@@ -4930,6 +4939,7 @@
|
||||
"View Pricing": "查看定价",
|
||||
"View the complete details for this": "查看此条",
|
||||
"View the complete details for this log entry": "查看此日志条目的完整详情",
|
||||
"View the complete details for this task": "查看该任务的完整详情",
|
||||
"View the complete error message and details": "查看完整错误信息与详情",
|
||||
"View the complete prompt and its English translation": "查看完整提示词及其英文翻译",
|
||||
"View the generated image": "查看生成的图片",
|
||||
|
||||
Vendored
+25
-18
@@ -55,28 +55,35 @@ export const colorToBgClass: Record<SemanticColor, string> = {
|
||||
slate: 'bg-slate-500',
|
||||
}
|
||||
|
||||
const identitySurfaceClassName =
|
||||
'[background-color:color-mix(in_oklch,currentColor_var(--identity-surface-mix),transparent)] [border-color:color-mix(in_oklch,currentColor_var(--identity-border-mix),transparent)]'
|
||||
|
||||
const identityColorClassMap: Record<SemanticColor, string> = {
|
||||
amber: 'bg-identity-amber/10 text-identity-amber',
|
||||
blue: 'bg-identity-blue/10 text-identity-blue',
|
||||
cyan: 'bg-identity-cyan/10 text-identity-cyan',
|
||||
green: 'bg-identity-green/10 text-identity-green',
|
||||
grey: 'bg-identity-grey/10 text-identity-grey',
|
||||
indigo: 'bg-identity-indigo/10 text-identity-indigo',
|
||||
'light-blue': 'bg-identity-light-blue/10 text-identity-light-blue',
|
||||
'light-green': 'bg-identity-green/10 text-identity-green',
|
||||
lime: 'bg-identity-lime/10 text-identity-lime',
|
||||
orange: 'bg-identity-orange/10 text-identity-orange',
|
||||
pink: 'bg-identity-pink/10 text-identity-pink',
|
||||
purple: 'bg-identity-purple/10 text-identity-purple',
|
||||
red: 'bg-identity-red/10 text-identity-red',
|
||||
slate: 'bg-identity-grey/10 text-identity-grey',
|
||||
teal: 'bg-identity-teal/10 text-identity-teal',
|
||||
violet: 'bg-identity-violet/10 text-identity-violet',
|
||||
yellow: 'bg-identity-yellow/10 text-identity-yellow',
|
||||
amber: 'text-identity-amber',
|
||||
blue: 'text-identity-blue',
|
||||
cyan: 'text-identity-cyan',
|
||||
green: 'text-identity-green',
|
||||
grey: 'text-identity-grey',
|
||||
indigo: 'text-identity-indigo',
|
||||
'light-blue': 'text-identity-light-blue',
|
||||
'light-green': 'text-identity-green',
|
||||
lime: 'text-identity-lime',
|
||||
orange: 'text-identity-orange',
|
||||
pink: 'text-identity-pink',
|
||||
purple: 'text-identity-purple',
|
||||
red: 'text-identity-red',
|
||||
slate: 'text-identity-grey',
|
||||
teal: 'text-identity-teal',
|
||||
violet: 'text-identity-violet',
|
||||
yellow: 'text-identity-yellow',
|
||||
}
|
||||
|
||||
export function getIdentityTextColorClass(name: string): string {
|
||||
return identityColorClassMap[stringToColor(name)]
|
||||
}
|
||||
|
||||
export function getIdentityColorClass(name: string): string {
|
||||
return identityColorClassMap[stringToColor(name)]
|
||||
return `${identitySurfaceClassName} ${getIdentityTextColorClass(name)}`
|
||||
}
|
||||
|
||||
export function getBgColorClass(color?: string): string {
|
||||
|
||||
Vendored
+9
-2
@@ -28,6 +28,7 @@ import { StrictMode } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { getStatus } from '@/lib/api'
|
||||
import { installBuildMetadata } from '@/lib/build-metadata'
|
||||
import { applyFaviconToDom } from '@/lib/dom-utils'
|
||||
@@ -115,7 +116,11 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
|
||||
// Render the app
|
||||
const rootElement = document.getElementById('root')!
|
||||
const rootElement = document.querySelector<HTMLDivElement>('#root')
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element not found')
|
||||
}
|
||||
|
||||
// Set document.title and favicon from cached status, then refresh from network
|
||||
;(function initSystemBranding() {
|
||||
try {
|
||||
@@ -165,7 +170,9 @@ if (!rootElement.innerHTML) {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<DirectionProvider>
|
||||
<RouterProvider router={router} />
|
||||
<TooltipProvider>
|
||||
<RouterProvider router={router} />
|
||||
</TooltipProvider>
|
||||
</DirectionProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
+10
-10
@@ -421,22 +421,22 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
[data-theme-preset]:not([data-theme-preset='default']):not(
|
||||
[data-theme-preset='anthropic']
|
||||
):not([data-theme-preset='simple-large']) {
|
||||
--card: color-mix(in oklch, var(--primary) 3%, var(--background));
|
||||
--popover: color-mix(in oklch, var(--primary) 5%, var(--background));
|
||||
--muted: color-mix(in oklch, var(--primary) 7%, var(--background));
|
||||
--card: color-mix(in oklch, var(--primary) 6%, var(--background));
|
||||
--popover: color-mix(in oklch, var(--primary) 8%, var(--background));
|
||||
--muted: color-mix(in oklch, var(--primary) 10%, var(--background));
|
||||
--muted-foreground: color-mix(
|
||||
in oklch,
|
||||
var(--foreground) 68%,
|
||||
var(--foreground) 74%,
|
||||
var(--primary)
|
||||
);
|
||||
--accent: color-mix(in oklch, var(--primary) 14%, var(--background));
|
||||
--accent: color-mix(in oklch, var(--primary) 18%, var(--background));
|
||||
--accent-foreground: var(--foreground);
|
||||
--border: color-mix(in oklch, var(--primary) 20%, var(--background));
|
||||
--input: color-mix(in oklch, var(--primary) 22%, var(--background));
|
||||
--sidebar: color-mix(in oklch, var(--primary) 4%, var(--background));
|
||||
--sidebar-accent: color-mix(in oklch, var(--primary) 14%, var(--background));
|
||||
--border: color-mix(in oklch, var(--primary) 28%, var(--background));
|
||||
--input: color-mix(in oklch, var(--primary) 30%, var(--background));
|
||||
--sidebar: color-mix(in oklch, var(--primary) 7%, var(--background));
|
||||
--sidebar-accent: color-mix(in oklch, var(--primary) 20%, var(--background));
|
||||
--sidebar-accent-foreground: var(--foreground);
|
||||
--sidebar-border: color-mix(in oklch, var(--primary) 18%, var(--background));
|
||||
--sidebar-border: color-mix(in oklch, var(--primary) 26%, var(--background));
|
||||
--success: var(--chart-2);
|
||||
--warning: var(--chart-4);
|
||||
--info: var(--chart-1);
|
||||
|
||||
Vendored
+87
-39
@@ -56,6 +56,8 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-subtle-foreground: var(--subtle-foreground);
|
||||
--color-faint-foreground: var(--faint-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
@@ -73,92 +75,113 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
* keeping compact badge text legible across light, dark, and preset themes. */
|
||||
--color-status-destructive: color-mix(
|
||||
in oklch,
|
||||
var(--destructive) 55%,
|
||||
var(--destructive) var(--status-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
--color-status-success: color-mix(
|
||||
in oklch,
|
||||
var(--success) 55%,
|
||||
var(--success) var(--status-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
--color-status-warning: color-mix(
|
||||
in oklch,
|
||||
var(--warning) 55%,
|
||||
var(--warning) var(--status-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
--color-status-info: color-mix(
|
||||
in oklch,
|
||||
var(--info) var(--status-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
--color-status-info: color-mix(in oklch, var(--info) 55%, var(--foreground));
|
||||
/* Metric identity accent; intentionally distinct from status semantics. */
|
||||
--color-metric-rpm: color-mix(
|
||||
in oklch,
|
||||
oklch(0.68 0.2 350) 55%,
|
||||
oklch(0.68 calc(0.2 * var(--identity-chroma-scale)) 350)
|
||||
var(--identity-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
/* Stable entity hues used by deterministic string-to-color identity tints. */
|
||||
/* Stable entity hues used by deterministic string-to-color identity tints.
|
||||
* Mixing toward the achromatic foreground dilutes chroma along with
|
||||
* lightness, so each hue pre-scales its chroma by the per-mode
|
||||
* `--identity-chroma-scale` (see :root / .dark). */
|
||||
--color-identity-amber: color-mix(
|
||||
in oklch,
|
||||
oklch(0.72 0.17 75) 55%,
|
||||
oklch(0.72 calc(0.17 * var(--identity-chroma-scale)) 75)
|
||||
var(--identity-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
--color-identity-blue: color-mix(
|
||||
in oklch,
|
||||
oklch(0.62 0.18 250) 55%,
|
||||
oklch(0.62 calc(0.18 * var(--identity-chroma-scale)) 250)
|
||||
var(--identity-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
--color-identity-cyan: color-mix(
|
||||
in oklch,
|
||||
oklch(0.7 0.14 205) 55%,
|
||||
oklch(0.7 calc(0.14 * var(--identity-chroma-scale)) 205)
|
||||
var(--identity-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
--color-identity-green: color-mix(
|
||||
in oklch,
|
||||
oklch(0.64 0.16 145) 55%,
|
||||
oklch(0.64 calc(0.16 * var(--identity-chroma-scale)) 145)
|
||||
var(--identity-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
--color-identity-grey: var(--muted-foreground);
|
||||
--color-identity-indigo: color-mix(
|
||||
in oklch,
|
||||
oklch(0.6 0.2 275) 55%,
|
||||
oklch(0.6 calc(0.2 * var(--identity-chroma-scale)) 275)
|
||||
var(--identity-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
--color-identity-light-blue: color-mix(
|
||||
in oklch,
|
||||
oklch(0.74 0.12 225) 55%,
|
||||
oklch(0.74 calc(0.12 * var(--identity-chroma-scale)) 225)
|
||||
var(--identity-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
--color-identity-lime: color-mix(
|
||||
in oklch,
|
||||
oklch(0.75 0.17 125) 55%,
|
||||
oklch(0.75 calc(0.17 * var(--identity-chroma-scale)) 125)
|
||||
var(--identity-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
--color-identity-orange: color-mix(
|
||||
in oklch,
|
||||
oklch(0.7 0.19 50) 55%,
|
||||
oklch(0.7 calc(0.19 * var(--identity-chroma-scale)) 50)
|
||||
var(--identity-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
--color-identity-pink: var(--color-metric-rpm);
|
||||
--color-identity-purple: color-mix(
|
||||
in oklch,
|
||||
oklch(0.62 0.2 305) 55%,
|
||||
oklch(0.62 calc(0.2 * var(--identity-chroma-scale)) 305)
|
||||
var(--identity-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
--color-identity-red: color-mix(
|
||||
in oklch,
|
||||
oklch(0.64 0.21 25) 55%,
|
||||
oklch(0.64 calc(0.21 * var(--identity-chroma-scale)) 25)
|
||||
var(--identity-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
--color-identity-teal: color-mix(
|
||||
in oklch,
|
||||
oklch(0.66 0.14 175) 55%,
|
||||
oklch(0.66 calc(0.14 * var(--identity-chroma-scale)) 175)
|
||||
var(--identity-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
--color-identity-violet: color-mix(
|
||||
in oklch,
|
||||
oklch(0.65 0.2 325) 55%,
|
||||
oklch(0.65 calc(0.2 * var(--identity-chroma-scale)) 325)
|
||||
var(--identity-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
--color-identity-yellow: color-mix(
|
||||
in oklch,
|
||||
oklch(0.78 0.16 95) 55%,
|
||||
oklch(0.78 calc(0.16 * var(--identity-chroma-scale)) 95)
|
||||
var(--identity-color-mix),
|
||||
var(--foreground)
|
||||
);
|
||||
--color-border: var(--border);
|
||||
@@ -201,7 +224,10 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
* in theme-presets.css. Default mirrors `--font-sans` so behavior is
|
||||
* identical when no font preference is set. */
|
||||
--font-body: var(--font-sans);
|
||||
--background: oklch(1 0 0);
|
||||
/* Light mode uses a quiet neutral canvas with white content surfaces.
|
||||
* Keeping the layers distinct prevents dense admin pages from collapsing
|
||||
* into one flat white plane while preserving the macaron accents. */
|
||||
--background: oklch(0.985 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
@@ -211,9 +237,11 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.95 0 0);
|
||||
--secondary-foreground: oklch(0.145 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted: oklch(0.955 0 0);
|
||||
--muted-foreground: oklch(0.49 0 0);
|
||||
--accent: oklch(0.95 0 0);
|
||||
--subtle-foreground: var(--muted-foreground);
|
||||
--faint-foreground: var(--muted-foreground);
|
||||
--accent: oklch(0.935 0 0);
|
||||
--accent-foreground: oklch(0.145 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
@@ -225,49 +253,53 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
--info-foreground: oklch(0.985 0 0);
|
||||
--neutral: oklch(0.708 0 0);
|
||||
--neutral-foreground: oklch(0.145 0 0);
|
||||
--border: oklch(0.93 0 0);
|
||||
--input: oklch(0.93 0 0);
|
||||
--border: oklch(0.895 0 0);
|
||||
--input: oklch(0.875 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.975 0 0);
|
||||
--sidebar: oklch(0.96 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.13 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.92 0 0);
|
||||
--sidebar-accent: oklch(0.9 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.145 0 0);
|
||||
--sidebar-border: oklch(0.93 0 0);
|
||||
--sidebar-border: oklch(0.895 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--skeleton-base: oklch(0.97 0 0);
|
||||
--skeleton-base: oklch(0.955 0 0);
|
||||
--skeleton-highlight: oklch(0.985 0 0);
|
||||
--table-row: var(--background);
|
||||
--table-header: color-mix(
|
||||
in oklch,
|
||||
var(--foreground) 1.5%,
|
||||
var(--background)
|
||||
);
|
||||
--status-color-mix: 65%;
|
||||
--identity-color-mix: 60%;
|
||||
/* Cancels the chroma diluted by the 60% mix toward the near-black
|
||||
* foreground (1 / 0.6), so light mode keeps the darkened lightness for
|
||||
* contrast but stays vivid instead of fading into pastel. */
|
||||
--identity-chroma-scale: 1.6667;
|
||||
--identity-surface-mix: 13%;
|
||||
--identity-border-mix: 28%;
|
||||
--table-row: var(--card);
|
||||
--table-header: color-mix(in oklch, var(--foreground) 3%, var(--table-row));
|
||||
--table-header-hover: color-mix(
|
||||
in oklch,
|
||||
var(--foreground) 3%,
|
||||
var(--background)
|
||||
var(--foreground) 5%,
|
||||
var(--table-row)
|
||||
);
|
||||
--table-disabled: color-mix(
|
||||
in oklch,
|
||||
var(--foreground) 5.5%,
|
||||
var(--background)
|
||||
var(--table-row)
|
||||
);
|
||||
--table-disabled-hover: color-mix(
|
||||
in oklch,
|
||||
var(--foreground) 7%,
|
||||
var(--background)
|
||||
var(--table-row)
|
||||
);
|
||||
--table-disabled-border: color-mix(
|
||||
in oklch,
|
||||
var(--foreground) 24%,
|
||||
var(--background)
|
||||
var(--table-row)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -285,6 +317,16 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
--secondary-foreground: oklch(0.965 0 0);
|
||||
--muted: oklch(0.305 0 0);
|
||||
--muted-foreground: oklch(0.78 0 0);
|
||||
--subtle-foreground: color-mix(
|
||||
in oklch,
|
||||
var(--muted-foreground) 60%,
|
||||
transparent
|
||||
);
|
||||
--faint-foreground: color-mix(
|
||||
in oklch,
|
||||
var(--muted-foreground) 40%,
|
||||
transparent
|
||||
);
|
||||
--accent: oklch(0.365 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
@@ -315,6 +357,12 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
--sidebar-ring: oklch(0.68 0 0);
|
||||
--skeleton-base: oklch(0.335 0 0);
|
||||
--skeleton-highlight: oklch(0.44 0 0);
|
||||
--status-color-mix: 55%;
|
||||
--identity-color-mix: 55%;
|
||||
/* Dark mode keeps the soft macaron pastels unchanged. */
|
||||
--identity-chroma-scale: 1;
|
||||
--identity-surface-mix: 10%;
|
||||
--identity-border-mix: 20%;
|
||||
--table-row: var(--background);
|
||||
--table-header: color-mix(in oklch, var(--foreground) 6%, var(--background));
|
||||
--table-header-hover: color-mix(
|
||||
|
||||
Reference in New Issue
Block a user