From 308e3e347a53835b921e6d19185a038cb53afdb5 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 11 Jul 2026 14:30:06 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(web):=20polish=20themed=20data?= =?UTF-8?q?=20views=20and=20add=20task=20log=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/bun.lock | 3 - web/classic/src/index.css | 16 + web/default/components.json | 2 +- web/default/package.json | 1 - .../ai-elements/response-renderer-alert.tsx | 24 +- .../components/data-table/core/badge-cell.tsx | 2 +- .../data-table/core/column-pinning.ts | 4 +- .../data-table/core/data-table-row.tsx | 4 +- .../data-table/layout/card-field.tsx | 2 +- .../data-table/layout/card-row-content.tsx | 6 +- .../data-table/toolbar/view-mode-toggle.tsx | 92 +-- web/default/src/components/drawer-layout.ts | 5 +- web/default/src/components/group-badge.tsx | 14 +- .../src/components/layout/components/glow.tsx | 4 +- .../src/components/navigation-progress.tsx | 9 +- web/default/src/components/provider-badge.tsx | 2 +- web/default/src/components/status-badge.tsx | 17 +- web/default/src/components/theme-switch.tsx | 6 +- web/default/src/components/ui/combobox.tsx | 2 +- .../src/components/ui/context-menu.tsx | 4 +- .../src/components/ui/dropdown-menu.tsx | 4 +- web/default/src/components/ui/kbd.tsx | 2 +- web/default/src/components/ui/menubar.tsx | 4 +- web/default/src/components/ui/select.tsx | 2 +- web/default/src/components/ui/sonner.tsx | 30 +- web/default/src/components/ui/tooltip.tsx | 10 +- .../channels/components/channel-card.tsx | 210 +++--- .../channels/components/channels-columns.tsx | 11 +- .../components/data-table-row-actions.tsx | 110 ++- .../dialogs/channel-test-dialog.tsx | 4 +- .../components/dialogs/codex-usage-dialog.tsx | 2 +- .../missing-models-confirmation-dialog.tsx | 2 +- .../dialogs/param-override-editor-dialog.tsx | 1 - .../components/numeric-spinner-input.tsx | 2 +- .../models/performance-overview.tsx | 2 +- .../src/features/dashboard/lib/api-info.ts | 8 +- .../keys/components/api-keys-cells.tsx | 2 +- .../components/deployment-access-guard.tsx | 8 +- .../models/components/deployments-columns.tsx | 14 +- .../components/dialogs/view-logs-dialog.tsx | 14 +- .../models/components/models-columns.tsx | 13 +- .../input/playground-parameter-panel.tsx | 5 +- .../components/message/message-actions.tsx | 2 +- .../components/message/message-error.tsx | 2 +- .../components/dynamic-pricing-breakdown.tsx | 47 +- .../dialogs/telegram-bind-dialog.tsx | 4 +- .../features/setup/components/admin-step.tsx | 4 +- .../setup/components/complete-step.tsx | 2 +- .../setup/components/database-step.tsx | 14 +- .../content/announcements-section.tsx | 10 +- .../integrations/amount-discount-dialog.tsx | 6 +- .../ionet-deployment-settings-section.tsx | 2 +- .../integrations/payment-settings-section.tsx | 12 +- .../waffo-pancake-settings-section.tsx | 10 +- .../components/columns/column-helpers.tsx | 10 +- .../columns/common-logs-columns.tsx | 29 +- .../columns/drawing-logs-columns.tsx | 10 +- .../components/columns/task-logs-columns.tsx | 184 ++--- .../dialogs/audio-preview-dialog.tsx | 48 +- .../components/dialogs/fail-reason-dialog.tsx | 6 +- .../components/dialogs/prompt-dialog.tsx | 4 +- .../dialogs/task-details-dialog.tsx | 633 ++++++++++++++++++ .../usage-logs/components/model-badge.tsx | 6 +- .../components/usage-logs-mobile-card.tsx | 61 +- .../src/features/usage-logs/lib/mappers.ts | 19 + web/default/src/features/usage-logs/types.ts | 7 + .../users/components/users-columns.tsx | 4 +- .../dialogs/payment-confirm-dialog.tsx | 2 +- web/default/src/i18n/locales/en.json | 12 +- web/default/src/i18n/locales/fr.json | 12 +- web/default/src/i18n/locales/ja.json | 12 +- web/default/src/i18n/locales/ru.json | 12 +- web/default/src/i18n/locales/vi.json | 12 +- web/default/src/i18n/locales/zh-TW.json | 14 +- web/default/src/i18n/locales/zh.json | 12 +- web/default/src/lib/colors.ts | 43 +- web/default/src/main.tsx | 11 +- web/default/src/styles/theme-presets.css | 20 +- web/default/src/styles/theme.css | 126 ++-- 79 files changed, 1376 insertions(+), 727 deletions(-) create mode 100644 web/default/src/features/usage-logs/components/dialogs/task-details-dialog.tsx diff --git a/web/bun.lock b/web/bun.lock index c702811d..1cd7f42e 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -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=="], diff --git a/web/classic/src/index.css b/web/classic/src/index.css index 387e2488..184f999c 100644 --- a/web/classic/src/index.css +++ b/web/classic/src/index.css @@ -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); diff --git a/web/default/components.json b/web/default/components.json index 4eaae346..58467254 100644 --- a/web/default/components.json +++ b/web/default/components.json @@ -18,7 +18,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "menuColor": "inverted", + "menuColor": "default", "menuAccent": "subtle", "registries": { "@ai-elements": "https://registry.ai-sdk.dev/{name}.json" diff --git a/web/default/package.json b/web/default/package.json index 736a6467..630f0110 100644 --- a/web/default/package.json +++ b/web/default/package.json @@ -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", diff --git a/web/default/src/components/ai-elements/response-renderer-alert.tsx b/web/default/src/components/ai-elements/response-renderer-alert.tsx index f5be2fc0..51b82d35 100644 --- a/web/default/src/components/ai-elements/response-renderer-alert.tsx +++ b/web/default/src/components/ai-elements/response-renderer-alert.tsx @@ -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 diff --git a/web/default/src/components/data-table/core/badge-cell.tsx b/web/default/src/components/data-table/core/badge-cell.tsx index 660baa6f..09a8bdf8 100644 --- a/web/default/src/components/data-table/core/badge-cell.tsx +++ b/web/default/src/components/data-table/core/badge-cell.tsx @@ -27,7 +27,7 @@ export function BadgeCell({ className, ...props }: BadgeCellProps) {
({ 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') diff --git a/web/default/src/components/data-table/layout/card-field.tsx b/web/default/src/components/data-table/layout/card-field.tsx index 392c6542..4b381c4d 100644 --- a/web/default/src/components/data-table/layout/card-field.tsx +++ b/web/default/src/components/data-table/layout/card-field.tsx @@ -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 diff --git a/web/default/src/components/data-table/layout/card-row-content.tsx b/web/default/src/components/data-table/layout/card-row-content.tsx index 8b889bcc..3e7abb46 100644 --- a/web/default/src/components/data-table/layout/card-row-content.tsx +++ b/web/default/src/components/data-table/layout/card-row-content.tsx @@ -77,12 +77,12 @@ export function CardRowContent(props: { return (
{props.compact && (titleCell || badgeCell) && ( -
-
+
+
{titleCell ? renderCellContent(titleCell) : null}
{badgeCell && ( -
+
{renderCellContent(badgeCell)}
)} diff --git a/web/default/src/components/data-table/toolbar/view-mode-toggle.tsx b/web/default/src/components/data-table/toolbar/view-mode-toggle.tsx index 79bc9744..b734a5ca 100644 --- a/web/default/src/components/data-table/toolbar/view-mode-toggle.tsx +++ b/web/default/src/components/data-table/toolbar/view-mode-toggle.tsx @@ -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 ( -
props.onChange(value as DataTableViewMode)} + className={props.className} > - {segments.map((segment) => { - const Icon = segment.icon - const isActive = segment.value === props.value - return ( - - 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' - )} - > - - - } - /> - - {segment.tooltip} - - - ) - })} -
+ + + + } + > + + {t('Table view')} + + + + } + > + + {t('Card view')} + + + ) } diff --git a/web/default/src/components/drawer-layout.ts b/web/default/src/components/drawer-layout.ts index c79ea945..778569b4 100644 --- a/web/default/src/components/drawer-layout.ts +++ b/web/default/src/components/drawer-layout.ts @@ -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 ) diff --git a/web/default/src/components/group-badge.tsx b/web/default/src/components/group-badge.tsx index bb4fc403..27e0e7eb 100644 --- a/web/default/src/components/group-badge.tsx +++ b/web/default/src/components/group-badge.tsx @@ -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) { {label} @@ -78,10 +85,11 @@ export function GroupBadge(props: GroupBadgeProps) { } return ( - - {badge} + + {badge} {ratio}x diff --git a/web/default/src/components/layout/components/glow.tsx b/web/default/src/components/layout/components/glow.tsx index c8191088..22a773eb 100644 --- a/web/default/src/components/layout/components/glow.tsx +++ b/web/default/src/components/layout/components/glow.tsx @@ -50,13 +50,13 @@ export function Glow({ className, variant, ...props }: GlowProps) { >
diff --git a/web/default/src/components/navigation-progress.tsx b/web/default/src/components/navigation-progress.tsx index 6181d36d..21073b03 100644 --- a/web/default/src/components/navigation-progress.tsx +++ b/web/default/src/components/navigation-progress.tsx @@ -32,12 +32,5 @@ export function NavigationProgress() { } }, [state.status]) - return ( - - ) + return } diff --git a/web/default/src/components/provider-badge.tsx b/web/default/src/components/provider-badge.tsx index 4cddc51a..9a5af178 100644 --- a/web/default/src/components/provider-badge.tsx +++ b/web/default/src/components/provider-badge.tsx @@ -38,7 +38,7 @@ export function ProviderBadge({ {icon && ( (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 && ( - + {labelChildren} )} @@ -249,7 +256,7 @@ export function StatusBadgeList(props: StatusBadgeListProps) { return (
{ - 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 ( diff --git a/web/default/src/components/ui/combobox.tsx b/web/default/src/components/ui/combobox.tsx index fa841a38..d30d293e 100644 --- a/web/default/src/components/ui/combobox.tsx +++ b/web/default/src/components/ui/combobox.tsx @@ -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} diff --git a/web/default/src/components/ui/context-menu.tsx b/web/default/src/components/ui/context-menu.tsx index 59ed33ba..d4f1dfea 100644 --- a/web/default/src/components/ui/context-menu.tsx +++ b/web/default/src/components/ui/context-menu.tsx @@ -72,7 +72,7 @@ function ContextMenuContent({ diff --git a/web/default/src/components/ui/dropdown-menu.tsx b/web/default/src/components/ui/dropdown-menu.tsx index 3bafce6f..e061bd26 100644 --- a/web/default/src/components/ui/dropdown-menu.tsx +++ b/web/default/src/components/ui/dropdown-menu.tsx @@ -59,7 +59,7 @@ function DropdownMenuContent({ ) { { - const { theme = 'system' } = useTheme() + const { resolvedTheme } = useTheme() return ( { '--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 } diff --git a/web/default/src/components/ui/tooltip.tsx b/web/default/src/components/ui/tooltip.tsx index 33a375df..a93835a6 100644 --- a/web/default/src/components/ui/tooltip.tsx +++ b/web/default/src/components/ui/tooltip.tsx @@ -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({ {children} - + + + diff --git a/web/default/src/features/channels/components/channel-card.tsx b/web/default/src/features/channels/components/channel-card.tsx index 80610555..64c2d817 100644 --- a/web/default/src/features/channels/components/channel-card.tsx +++ b/web/default/src/features/channels/components/channel-card.tsx @@ -17,22 +17,24 @@ along with this program. If not, see . 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 = { + 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 ( - - -
-
- {!isTagRow && selectCell && ( - {selectCell} +
+
+
+ {!isTagRow && selectCell && ( + {selectCell} + )} +
{typeCell}
+
+
+ {showStatusBadge && statusCell} + + {actionsCell} + +
+
+ +
+
+
+ {!isTagRow && ( +
+ #{sensitiveVisible ? row.original.id : SENSITIVE_MASK} +
)} - -
- {visibleColumnIds.has('name') && ( -
- {nameCell} -
- )} - {visibleColumnIds.has('type') && ( -
{typeCell}
- )} -
- -
- {visibleColumnIds.has('status') && statusCell} - {actionsCell} -
+ {nameCell}
- {hasStatRows && ( -
- {showId && ( - - {idCell} - - )} - {visibleColumnIds.has('balance') && ( - - {balanceCell} - - )} - {visibleColumnIds.has('response_time') && ( - - {responseCell} - - )} - {showTestTime && ( - - {testCell} - - )} - {visibleColumnIds.has('priority') && ( - - {priorityCell} - - )} - {visibleColumnIds.has('weight') && ( - - {weightCell} - - )} - {showTag && ( - - {tagCell} - - )} +
+
{fieldLabels.balance}
+
+ {balanceCell ?? -}
- )} - - {hasBadgeSections && ( -
- {visibleColumnIds.has('group') && ( - - {groupsCell ?? ( - - - )} - - )} - {showModels && ( - - {modelsCell ?? ( - - - )} - - )} -
- )} +
- - + +
+ {t('Priority')} + {t('Weight')} +
{priorityCell}
+
{weightCell}
+ + {fieldLabels.response_time} + + + {fieldLabels.test_time} + +
+ {responseCell ?? -} +
+
+ {testCell ?? -} +
+
+
+ +
+ {groups.length > 0 ? ( +
+ {groups.map((group) => ( + + ))} +
+ ) : ( + - + )} +
+
) } -export const ChannelCard = ChannelCardComponent +export const ChannelCard = memo(ChannelCardComponent) diff --git a/web/default/src/features/channels/components/channels-columns.tsx b/web/default/src/features/channels/components/channels-columns.tsx index 676b4015..3a7ce392 100644 --- a/web/default/src/features/channels/components/channels-columns.tsx +++ b/web/default/src/features/channels/components/channels-columns.tsx @@ -456,7 +456,7 @@ function BalanceCell({ channel }: { channel: Channel }) { variant={remainingBadgeVariant} size='sm' render={ - - - ) -} - export function useTaskLogsColumns(isAdmin: boolean): ColumnDef[] { const { t } = useTranslation() const columns: ColumnDef[] = [ @@ -106,11 +57,11 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef[] { {formatTimestampToDate(submitTime, 'seconds')} {log.finish_time ? ( - + {formatTimestampToDate(log.finish_time, 'seconds')} ) : ( - - + - )}
) @@ -180,20 +131,21 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef[] { const log = row.original const taskId = row.getValue('task_id') as string if (!taskId) { - return - + return - } return ( -
+
{taskId} - - {t(log.platform)} · {t(taskActionMapper.getLabel(log.action))} + + {getTaskPlatformName(log.platform)} ·{' '} + {t(taskActionMapper.getLabel(log.action))}
) @@ -229,79 +181,14 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef[] { }, createProgressColumn({ 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).audio_url - ) - ) { - return - } - } - - 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 ( - - {t('Click to preview video')} - - ) - } - - if (!failReason) { - return - - } - - return ( - <> - - - - ) + return }, - size: 200, - maxSize: 220, + size: 120, + maxSize: 140, meta: { cardRole: 'secondary', cardOrder: 20, @@ -313,3 +200,34 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef[] { 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 ( + <> + + + + ) +} diff --git a/web/default/src/features/usage-logs/components/dialogs/audio-preview-dialog.tsx b/web/default/src/features/usage-logs/components/dialogs/audio-preview-dialog.tsx index f95274fb..e426e64e 100644 --- a/web/default/src/features/usage-logs/components/dialogs/audio-preview-dialog.tsx +++ b/web/default/src/features/usage-logs/components/dialogs/audio-preview-dialog.tsx @@ -16,15 +16,13 @@ along with this program. If not, see . 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(null) @@ -135,39 +127,3 @@ function AudioClipCard({ clip }: { clip: AudioClip }) {
) } - -export function AudioPreviewDialog(props: AudioPreviewDialogProps) { - const { t } = useTranslation() - const clips = Array.isArray(props.clips) ? props.clips : [] - - return ( - - - {t('Audio Preview')} - - } - contentClassName='sm:max-w-lg' - titleClassName='flex items-center gap-2' - contentHeight='auto' - bodyClassName='space-y-4' - > - {clips.length === 0 ? ( -

- {t('None')} -

- ) : ( - -
- {clips.map((clip, idx) => ( - - ))} -
-
- )} -
- ) -} diff --git a/web/default/src/features/usage-logs/components/dialogs/fail-reason-dialog.tsx b/web/default/src/features/usage-logs/components/dialogs/fail-reason-dialog.tsx index d16bf572..35bc7c5d 100644 --- a/web/default/src/features/usage-logs/components/dialogs/fail-reason-dialog.tsx +++ b/web/default/src/features/usage-logs/components/dialogs/fail-reason-dialog.tsx @@ -55,7 +55,7 @@ export function FailReasonDialog({ -
+
-

+

{failReason || '-'}

diff --git a/web/default/src/features/usage-logs/components/dialogs/prompt-dialog.tsx b/web/default/src/features/usage-logs/components/dialogs/prompt-dialog.tsx index 444323a0..338d10e7 100644 --- a/web/default/src/features/usage-logs/components/dialogs/prompt-dialog.tsx +++ b/web/default/src/features/usage-logs/components/dialogs/prompt-dialog.tsx @@ -65,7 +65,7 @@ export function PromptDialog({ title={t('Copy to clipboard')} > {copiedText === prompt ? ( - + ) : ( )} @@ -91,7 +91,7 @@ export function PromptDialog({ title={t('Copy to clipboard')} > {copiedText === promptEn ? ( - + ) : ( )} diff --git a/web/default/src/features/usage-logs/components/dialogs/task-details-dialog.tsx b/web/default/src/features/usage-logs/components/dialogs/task-details-dialog.tsx new file mode 100644 index 00000000..2e9c679f --- /dev/null +++ b/web/default/src/features/usage-logs/components/dialogs/task-details-dialog.tsx @@ -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 . + +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([ + 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 { + 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, + 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 ( +
+ + {props.label} + + + {props.value} + +
+ ) +} + +function DetailSection(props: { + icon?: React.ReactNode + label: string + variant?: 'default' | 'destructive' + action?: React.ReactNode + children: React.ReactNode +}) { + const isDestructive = props.variant === 'destructive' + return ( +
+
+ + {props.action} +
+
+ {props.children} +
+
+ ) +} + +function CopyButton(props: { + text: string + copied: boolean + onCopy: () => void +}) { + const { t } = useTranslation() + return ( + + ) +} + +function VideoPreview({ url }: { url: string }) { + const { t } = useTranslation() + const [hasError, setHasError] = useState(false) + + if (hasError) { + return ( +
+ + {t('Failed to load video')} + + +
+ ) + } + + return ( +