@@ -107,15 +109,9 @@ export function ProfileSecurityCard({
item.variant === 'destructive' ? 'border-destructive/30' : ''
}`}
>
-
{item.title}
diff --git a/web/default/src/features/profile/components/profile-settings-card.tsx b/web/default/src/features/profile/components/profile-settings-card.tsx
index 997f74f4e8..9fa2d0f2e0 100644
--- a/web/default/src/features/profile/components/profile-settings-card.tsx
+++ b/web/default/src/features/profile/components/profile-settings-card.tsx
@@ -56,8 +56,8 @@ export function ProfileSettingsCard({
- {Array.from({ length: 3 }).map((_, i) => (
-
+ {['bindings', 'preferences', 'notifications'].map((key) => (
+
))}
@@ -69,6 +69,7 @@ export function ProfileSettingsCard({
title={t('Settings')}
description={t('Configure your account preferences and integrations')}
icon={ }
+ iconTone='info'
disableHoverEffect
>
diff --git a/web/default/src/features/profile/components/sidebar-modules-card.tsx b/web/default/src/features/profile/components/sidebar-modules-card.tsx
index ae5e7e1e2d..1196e8d57d 100644
--- a/web/default/src/features/profile/components/sidebar-modules-card.tsx
+++ b/web/default/src/features/profile/components/sidebar-modules-card.tsx
@@ -29,6 +29,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
+import { IconBadge } from '@/components/ui/icon-badge'
import { Switch } from '@/components/ui/switch'
import { api } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store'
@@ -204,9 +205,9 @@ export function SidebarModulesCard() {
-
-
-
+
+
+
{t('Sidebar Personal Settings')}
diff --git a/web/default/src/features/profile/components/two-fa-card.tsx b/web/default/src/features/profile/components/two-fa-card.tsx
index e67085e30a..60ed416dea 100644
--- a/web/default/src/features/profile/components/two-fa-card.tsx
+++ b/web/default/src/features/profile/components/two-fa-card.tsx
@@ -28,6 +28,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
+import { IconBadge } from '@/components/ui/icon-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { useDialogs } from '@/hooks/use-dialog'
@@ -82,9 +83,9 @@ export function TwoFACard({ loading: pageLoading }: TwoFACardProps) {
{/* Status Section */}
-
-
-
+
+
+
{t('Two-Step Verification')}
diff --git a/web/default/src/features/redemption-codes/components/redemptions-mobile-list.tsx b/web/default/src/features/redemption-codes/components/redemptions-mobile-list.tsx
new file mode 100644
index 0000000000..1a6349daba
--- /dev/null
+++ b/web/default/src/features/redemption-codes/components/redemptions-mobile-list.tsx
@@ -0,0 +1,175 @@
+/*
+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 type { Table as TanstackTable } from '@tanstack/react-table'
+import { Database } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+
+import { DISABLED_ROW_MOBILE } from '@/components/data-table'
+import { MaskedValueDisplay } from '@/components/masked-value-display'
+import { StatusBadge } from '@/components/status-badge'
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from '@/components/ui/empty'
+import { Skeleton } from '@/components/ui/skeleton'
+import { formatQuota } from '@/lib/format'
+import { cn } from '@/lib/utils'
+
+import { REDEMPTION_STATUS, REDEMPTION_STATUSES } from '../constants'
+import { isRedemptionExpired } from '../lib'
+import type { Redemption } from '../types'
+import { DataTableRowActions } from './data-table-row-actions'
+
+const MOBILE_SKELETON_KEYS = [
+ 'redemption-mobile-skeleton-1',
+ 'redemption-mobile-skeleton-2',
+ 'redemption-mobile-skeleton-3',
+ 'redemption-mobile-skeleton-4',
+ 'redemption-mobile-skeleton-5',
+]
+
+function RedemptionsMobileSkeleton() {
+ return (
+
+ {MOBILE_SKELETON_KEYS.map((key) => (
+
+ ))}
+
+ )
+}
+
+interface RedemptionsMobileListProps {
+ table: TanstackTable
+ isLoading: boolean
+}
+
+export function RedemptionsMobileList(props: RedemptionsMobileListProps) {
+ const { t } = useTranslation()
+ const rows = props.table.getRowModel().rows
+
+ if (props.isLoading) return
+
+ if (!rows.length) {
+ return (
+
+
+
+
+
+
+ {t('No Redemption Codes Found')}
+
+ {t(
+ 'No redemption codes available. Create your first redemption code to get started.'
+ )}
+
+
+
+
+ )
+ }
+
+ return (
+
+ {rows.map((row) => {
+ const redemption = row.original
+ const expired = isRedemptionExpired(
+ redemption.expired_time,
+ redemption.status
+ )
+ const statusConfig = REDEMPTION_STATUSES[redemption.status]
+ const maskedKey = `${redemption.key.slice(0, 8)}******${redemption.key.slice(-8)}`
+
+ return (
+
+
+
+
+ {redemption.name}
+
+
+ {t('Redemption Code')}
+
+
+ {expired ? (
+
+ ) : (
+ statusConfig && (
+
+ )
+ )}
+
+
+
+
+
+ {t('Quota')}
+
+ {formatQuota(redemption.quota)}
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/web/default/src/features/redemption-codes/components/redemptions-table.tsx b/web/default/src/features/redemption-codes/components/redemptions-table.tsx
index dce04c1c7c..391d64cff4 100644
--- a/web/default/src/features/redemption-codes/components/redemptions-table.tsx
+++ b/web/default/src/features/redemption-codes/components/redemptions-table.tsx
@@ -41,6 +41,7 @@ import { isRedemptionExpired } from '../lib'
import type { Redemption } from '../types'
import { DataTableBulkActions } from './data-table-bulk-actions'
import { useRedemptionsColumns } from './redemptions-columns'
+import { RedemptionsMobileList } from './redemptions-mobile-list'
import { useRedemptions } from './redemptions-provider'
const route = getRouteApi('/_authenticated/redemption-codes/')
@@ -179,6 +180,7 @@ export function RedemptionsTable() {
},
],
}}
+ mobile={ }
getRowClassName={(row, { isMobile }) => {
if (!isDisabledRedemptionRow(row.original)) return undefined
return isMobile ? DISABLED_ROW_MOBILE : DISABLED_ROW_DESKTOP
diff --git a/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx b/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx
index 4f2dd1770b..1a77ce1cde 100644
--- a/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx
+++ b/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx
@@ -41,6 +41,7 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
+import { IconBadge } from '@/components/ui/icon-badge'
import { Input } from '@/components/ui/input'
import {
Select,
@@ -279,7 +280,9 @@ export function SubscriptionsMutateDrawer({
{/* Basic Info */}
-
+
+
+
{t('Basic Info')}
@@ -328,7 +331,9 @@ export function SubscriptionsMutateDrawer({
step='0.01'
min={0}
onChange={(e) =>
- field.onChange(parseFloat(e.target.value) || 0)
+ field.onChange(
+ Number.parseFloat(e.target.value) || 0
+ )
}
/>
@@ -364,7 +369,9 @@ export function SubscriptionsMutateDrawer({
})
}
onChange={(e) =>
- field.onChange(parseFloat(e.target.value) || 0)
+ field.onChange(
+ Number.parseFloat(e.target.value) || 0
+ )
}
/>
@@ -480,7 +487,9 @@ export function SubscriptionsMutateDrawer({
type='number'
min={0}
onChange={(e) =>
- field.onChange(parseInt(e.target.value, 10) || 0)
+ field.onChange(
+ Number.parseInt(e.target.value, 10) || 0
+ )
}
/>
@@ -504,7 +513,9 @@ export function SubscriptionsMutateDrawer({
{...field}
type='number'
onChange={(e) =>
- field.onChange(parseInt(e.target.value, 10) || 0)
+ field.onChange(
+ Number.parseInt(e.target.value, 10) || 0
+ )
}
/>
@@ -573,7 +584,9 @@ export function SubscriptionsMutateDrawer({
{/* Duration Settings */}
-
+
+
+
{t('Duration Settings')}
@@ -585,12 +598,10 @@ export function SubscriptionsMutateDrawer({
{t('Duration Unit')}
({
- value: o.value,
- label: o.label,
- })),
- ]}
+ items={durationUnitOpts.map((o) => ({
+ value: o.value,
+ label: o.label,
+ }))}
onValueChange={field.onChange}
value={field.value}
>
@@ -627,7 +638,9 @@ export function SubscriptionsMutateDrawer({
type='number'
min={1}
onChange={(e) =>
- field.onChange(parseInt(e.target.value, 10) || 0)
+ field.onChange(
+ Number.parseInt(e.target.value, 10) || 0
+ )
}
/>
@@ -648,7 +661,9 @@ export function SubscriptionsMutateDrawer({
type='number'
min={1}
onChange={(e) =>
- field.onChange(parseInt(e.target.value, 10) || 0)
+ field.onChange(
+ Number.parseInt(e.target.value, 10) || 0
+ )
}
/>
@@ -663,7 +678,9 @@ export function SubscriptionsMutateDrawer({
{/* Quota Reset */}
-
+
+
+
{t('Quota Reset')}
@@ -675,12 +692,10 @@ export function SubscriptionsMutateDrawer({
{t('Reset Cycle')}
({
- value: o.value,
- label: o.label,
- })),
- ]}
+ items={resetPeriodOpts.map((o) => ({
+ value: o.value,
+ label: o.label,
+ }))}
onValueChange={field.onChange}
value={field.value}
>
@@ -717,7 +732,9 @@ export function SubscriptionsMutateDrawer({
min={0}
disabled={resetPeriod !== 'custom'}
onChange={(e) =>
- field.onChange(parseInt(e.target.value, 10) || 0)
+ field.onChange(
+ Number.parseInt(e.target.value, 10) || 0
+ )
}
/>
@@ -731,7 +748,9 @@ export function SubscriptionsMutateDrawer({
{/* Payment Config */}
-
+
+
+
{t('Third-party Payment Config')}
diff --git a/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx b/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx
index b1af4e02e6..b0c6f1bdab 100644
--- a/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx
+++ b/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx
@@ -16,11 +16,12 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import { type ColumnDef } from '@tanstack/react-table'
+import type { ColumnDef } from '@tanstack/react-table'
import { GitBranch, Sparkles, KeyRound } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
+import { GroupBadge } from '@/components/group-badge'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
@@ -74,19 +75,19 @@ function formatRatioCompact(ratio: number | undefined): string {
: ratio.toFixed(4).replace(/\.?0+$/, '')
}
-function getGroupRatioText(other: LogOtherData | null): string | null {
+function getGroupRatio(other: LogOtherData | null): number | null {
const userGroupRatio = other?.user_group_ratio
if (
userGroupRatio != null &&
userGroupRatio !== -1 &&
Number.isFinite(userGroupRatio)
) {
- return `${formatRatioCompact(userGroupRatio)}x`
+ return userGroupRatio
}
const groupRatio = other?.group_ratio
if (groupRatio != null && groupRatio !== 1 && Number.isFinite(groupRatio)) {
- return `${formatRatioCompact(groupRatio)}x`
+ return groupRatio
}
return null
@@ -218,10 +219,11 @@ function buildTypeDetailSegments(
})
}
} else {
- const isPerCall = isPerCallBilling(other.model_price)
- if (isPerCall) {
+ const modelPrice = other.model_price
+ const isPerCall = isPerCallBilling(modelPrice)
+ if (isPerCall && modelPrice != null) {
segments.push({
- text: `${t('Per-call')} · ${formatBillingCurrencyFromUSD(other.model_price!, priceOpts)}`,
+ text: `${t('Per-call')} · ${formatBillingCurrencyFromUSD(modelPrice, priceOpts)}`,
})
} else if (other.model_ratio != null) {
const inputPriceUSD = other.model_ratio * 2.0
@@ -553,13 +555,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] {
const displayName = sensitiveVisible ? tokenName : '••••'
let group = log.group
if (!group) group = other?.group || ''
-
- const metaParts: string[] = []
- const groupRatioText = getGroupRatioText(other)
- if (group) {
- metaParts.push(sensitiveVisible ? group : '••••')
- }
- if (groupRatioText) metaParts.push(groupRatioText)
+ const groupRatio = getGroupRatio(other)
return (
@@ -582,9 +578,23 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] {
)}
- {metaParts.length > 0 && (
-
- {metaParts.join(' · ')}
+ {(group || groupRatio != null) && (
+
+ {group ? (
+
+ ) : null}
+ {group && groupRatio != null ? ' ' : null}
+ {groupRatio != null ? (
+
+ {formatRatioCompact(groupRatio)}x
+
+ ) : null}
)}
@@ -684,26 +694,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] {
)
},
},
- {
- accessorKey: 'use_time',
- header: t('Timing'),
- cell: ({ row }) => {
- const log = row.original
- if (!isTimingLogType(log.type)) return null
-
- const useTime = row.getValue('use_time') as number
- const other = parseLogOther(log.other)
-
- return (
-
- )
- },
- },
-
{
accessorKey: 'quota',
header: t('Cost'),
@@ -756,6 +746,27 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] {
},
},
+ {
+ accessorKey: 'use_time',
+ header: t('Timing'),
+ cell: ({ row }) => {
+ const log = row.original
+ if (!isTimingLogType(log.type)) return null
+
+ const useTime = row.getValue('use_time') as number
+ const other = parseLogOther(log.other)
+
+ return (
+
+ )
+ },
+ },
+
{
accessorKey: 'content',
header: t('Details'),
@@ -767,6 +778,36 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] {
const segments = buildDetailSegments(log, other, t, isAdmin)
const primary = segments[0]
const hasMore = segments.length > 1
+ let primaryTextClass = 'text-foreground'
+ if (primary?.muted) {
+ primaryTextClass = 'text-muted-foreground/60'
+ } else if (primary?.danger) {
+ primaryTextClass = 'text-red-600 dark:text-red-400'
+ }
+ let detailPreview = —
+ if (primary) {
+ detailPreview = (
+
+ {primary.text}
+ {hasMore && (
+
+ +{segments.length - 1}
+
+ )}
+
+ )
+ } else if (log.content) {
+ detailPreview = (
+
+ {log.content}
+
+ )
+ }
return (
<>
@@ -776,31 +817,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] {
onClick={() => setDialogOpen(true)}
title={t('Click to view full details')}
>
- {primary ? (
-
- {primary.text}
- {hasMore && (
-
- +{segments.length - 1}
-
- )}
-
- ) : log.content ? (
-
- {log.content}
-
- ) : (
- —
- )}
+ {detailPreview}
(
const navigate = useNavigate()
const queryClient = useQueryClient()
const searchParams = route.useSearch()
- const isAdmin = useIsAdmin()
+ const { isAdminView: isAdmin } = useLogsViewScope()
const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext()
const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
diff --git a/web/default/src/features/usage-logs/components/common-logs-stats.tsx b/web/default/src/features/usage-logs/components/common-logs-stats.tsx
index 5219c09ac2..dd754e227a 100644
--- a/web/default/src/features/usage-logs/components/common-logs-stats.tsx
+++ b/web/default/src/features/usage-logs/components/common-logs-stats.tsx
@@ -21,14 +21,13 @@ import { getRouteApi } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Skeleton } from '@/components/ui/skeleton'
-import { useIsAdmin } from '@/hooks/use-admin'
import { formatLogQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
import { getLogStats, getUserLogStats } from '../api'
import { DEFAULT_LOG_STATS } from '../constants'
import { buildApiParams } from '../lib/utils'
-import { useUsageLogsContext } from './usage-logs-provider'
+import { useLogsViewScope, useUsageLogsContext } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section')
@@ -50,7 +49,7 @@ function StatBadge(props: {
export function CommonLogsStats() {
const { t } = useTranslation()
- const isAdmin = useIsAdmin()
+ const { isAdminView: isAdmin } = useLogsViewScope()
const searchParams = route.useSearch()
const { sensitiveVisible } = useUsageLogsContext()
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 01bdf5582c..e65202681f 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
@@ -24,6 +24,7 @@ import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
+import { IconBadge } from '@/components/ui/icon-badge'
import { ScrollArea } from '@/components/ui/scroll-area'
export interface AudioClip {
@@ -153,7 +154,9 @@ export function AudioPreviewDialog(props: AudioPreviewDialogProps) {
onOpenChange={props.onOpenChange}
title={
<>
-
+
+
+
{t('Audio Preview')}
>
}
diff --git a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx
index 9b19a6d5d6..d38465b3bb 100644
--- a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx
+++ b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx
@@ -36,6 +36,7 @@ import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
+import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge'
import { Label } from '@/components/ui/label'
import { DynamicPricingBreakdown } from '@/features/pricing/components/dynamic-pricing-breakdown'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
@@ -108,11 +109,13 @@ function DetailRow(props: {
function DetailSection(props: {
icon?: React.ReactNode
+ iconTone?: IconBadgeTone
label: string
variant?: 'default' | 'danger'
children: React.ReactNode
}) {
const isDanger = props.variant === 'danger'
+ const iconTone = isDanger ? 'destructive' : props.iconTone
return (
- {props.icon}
+ {props.icon && (
+
+ {props.icon}
+
+ )}
{props.label}
- {rows.map((row, idx) => (
-
+ {rows.map((row) => (
+
))}
)
@@ -401,8 +408,8 @@ function TokenBreakdown(props: { log: UsageLog; other: LogOtherData }) {
return (
- {rows.map((row, idx) => (
-
+ {rows.map((row) => (
+
))}
)
@@ -543,6 +550,12 @@ export function DetailsDialog(props: DetailsDialogProps) {
const useChannel = other?.admin_info?.use_channel
const channelChain =
useChannel && useChannel.length > 0 ? useChannel.join(' → ') : undefined
+ let reasoningEffortVariant: StatusBadgeProps['variant'] = 'green'
+ if (other?.reasoning_effort === 'high') {
+ reasoningEffortVariant = 'orange'
+ } else if (other?.reasoning_effort === 'medium') {
+ reasoningEffortVariant = 'yellow'
+ }
return (
}
+ iconTone='success'
label={t('Top-up Audit Info')}
>
- {topupAuditFields.map((field, idx) => (
+ {topupAuditFields.map((field) => (
}
+ iconTone='info'
label={t('Operation Audit Info')}
>
{operationText != null && (
@@ -889,14 +904,15 @@ export function DetailsDialog(props: DetailsDialogProps) {
{isLogin && loginAuditFields.length > 0 && (
}
+ iconTone='info'
label={t('Login Info')}
>
{operationText != null && (
)}
- {loginAuditFields.map((field, idx) => (
+ {loginAuditFields.map((field) => (
}
+ iconTone='chart-4'
label={t('Audio Tokens')}
>
{other.audio_input != null && other.audio_input > 0 && (
@@ -949,13 +966,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
value={
@@ -1139,14 +1150,15 @@ export function DetailsDialog(props: DetailsDialogProps) {
{other?.po && Array.isArray(other.po) && other.po.length > 0 && (
}
+ iconTone='chart-3'
label={`${t('Param Override')} (${other.po.length})`}
>
- {other.po.filter(Boolean).map((line, idx) => {
+ {other.po.filter(Boolean).map((line) => {
const parsed = parseAuditLine(line)
if (!parsed) return null
return (
(props: LogsFilterToolbarProps) {
const { t } = useTranslation()
const [advancedOpen, setAdvancedOpen] = useState(false)
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
+ const [mobilePanelCollapsed, setMobilePanelCollapsed] = useState(false)
const isMobile = useMediaQuery('(max-width: 640px)')
const hasAdvancedFilters = props.advancedFilters != null
@@ -139,11 +140,36 @@ export function LogsFilterToolbar(props: LogsFilterToolbarProps) {
-
{props.mobilePinnedFilters}
+ {!mobilePanelCollapsed && (
+
{props.mobilePinnedFilters}
+ )}
-
- {props.stats}
+
+ {!mobilePanelCollapsed && props.stats}
+
setMobilePanelCollapsed((collapsed) => !collapsed)}
+ aria-expanded={!mobilePanelCollapsed}
+ aria-label={
+ mobilePanelCollapsed ? t('Expand') : t('Collapse')
+ }
+ className='text-muted-foreground hover:text-foreground mr-auto size-7'
+ >
+
+
{props.actionStart}
(props: TaskLogsFilterBarProps) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const searchParams = route.useSearch()
- const isAdmin = useIsAdmin()
+ const { isAdminView: isAdmin } = useLogsViewScope()
const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
const [filters, setFilters] = useState(() => {
diff --git a/web/default/src/features/usage-logs/components/timing-metrics-cell.tsx b/web/default/src/features/usage-logs/components/timing-metrics-cell.tsx
index 409378183a..cf737e12a8 100644
--- a/web/default/src/features/usage-logs/components/timing-metrics-cell.tsx
+++ b/web/default/src/features/usage-logs/components/timing-metrics-cell.tsx
@@ -19,6 +19,11 @@ For commercial licensing, please contact support@quantumnous.com
import { CircleAlert } from 'lucide-react'
import { useTranslation } from 'react-i18next'
+import {
+ dotColorMap,
+ textColorMap,
+ type StatusVariant,
+} from '@/components/status-badge'
import {
Tooltip,
TooltipContent,
@@ -28,62 +33,124 @@ import {
import { formatUseTime } from '@/lib/format'
import { cn } from '@/lib/utils'
+import { getFirstResponseTimeColor, getResponseTimeColor } from '../lib/format'
import type { LogOtherData } from '../types'
+/**
+ * Softened fills for the full-height timing bar. The bar sits directly beside
+ * dense numeric text, so the saturated `dotColorMap` tones (tuned for small
+ * dots and badges) read as too high-contrast at that size; a translucent fill
+ * keeps the status legible while matching the page's muted palette.
+ */
+const barColorMap: Record = {
+ ...dotColorMap,
+ success: 'bg-success/90',
+ warning: 'bg-warning/80',
+ danger: 'bg-destructive/80',
+ neutral: 'bg-neutral/80',
+}
+
interface TimingMetricsCellProps {
useTimeSec: number
+ completionTokens: number
frtMs?: number
isStream: boolean
className?: string
+ /**
+ * `bar` (default) draws a full-height color segment beside the labels,
+ * matching the dense desktop table. `dot` swaps that segment for small
+ * status dots inline with each label, matching the lighter-weight status
+ * indicator used elsewhere on the mobile card.
+ */
+ indicator?: 'bar' | 'dot'
}
export function TimingMetricsCell(props: TimingMetricsCellProps) {
const { t } = useTranslation()
+ const indicator = props.indicator ?? 'bar'
const showFirstToken = props.isStream
- const hasFrt = props.frtMs != null && props.frtMs > 0
- const firstTokenLabel = hasFrt
- ? formatUseTime(props.frtMs! / 1000)
- : t('N/A')
+ const firstTokenSeconds =
+ props.frtMs != null && props.frtMs > 0 ? props.frtMs / 1000 : null
+ const firstTokenVariant: StatusVariant =
+ firstTokenSeconds == null
+ ? 'neutral'
+ : getFirstResponseTimeColor(firstTokenSeconds)
+ const totalTimeVariant = getResponseTimeColor(
+ props.useTimeSec,
+ props.completionTokens
+ )
+ const firstTokenLabel =
+ firstTokenSeconds == null ? t('N/A') : formatUseTime(firstTokenSeconds)
const totalTimeLabel = formatUseTime(props.useTimeSec)
+ const labels = (
+
+ {showFirstToken && (
+
+ {indicator === 'dot' && (
+
+ )}
+
+ {t('First token')}
+
+
+ {firstTokenLabel}
+
+
+ )}
+
+ {indicator === 'dot' && (
+
+ )}
+
+ {t('Duration')}
+
+
+ {totalTimeLabel}
+
+
+
+ )
+
+ if (indicator === 'dot') {
+ return (
+
+ {labels}
+
+ )
+ }
+
return (
-
-
-
- {t('First token')}
-
-
- {showFirstToken ? firstTokenLabel : '—'}
-
-
-
-
- {t('Duration')}
-
- {totalTimeLabel}
-
-
+ >
+ {showFirstToken && (
+ <>
+
+
+ >
+ )}
+
+ {labels}
)
}
@@ -98,13 +165,12 @@ interface StreamTpsCellProps {
export function StreamTpsCell(props: StreamTpsCellProps) {
const { t } = useTranslation()
const showStreamError =
- props.isStream &&
- props.streamStatus &&
- props.streamStatus.status !== 'ok'
+ props.isStream && props.streamStatus && props.streamStatus.status !== 'ok'
const tpsLabel =
props.tokensPerSecond != null
? `${Math.round(props.tokensPerSecond)} t/s`
: '—'
+ const streamLabel = props.isStream ? t('Stream') : t('Non-stream')
return (
-
- {props.isStream ? t('Stream') : t('Non-stream')}
+
+ {streamLabel}
{showStreamError && (
diff --git a/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx b/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx
index a9364fee8b..cb28966b79 100644
--- a/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx
+++ b/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx
@@ -25,6 +25,7 @@ import {
textColorMap,
type StatusVariant,
} from '@/components/status-badge'
+import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
Empty,
EmptyDescription,
@@ -33,12 +34,21 @@ import {
EmptyTitle,
} from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
+import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import { LOG_TYPE_ENUM } from '../constants'
-import { getLogTypeConfig } from '../lib/utils'
+import type { UsageLog } from '../data/schema'
+import { parseLogOther } from '../lib/format'
+import {
+ getLogTypeConfig,
+ isDisplayableLogType,
+ isTimingLogType,
+} from '../lib/utils'
import type { LogCategory } from '../types'
+import { StreamTpsCell, TimingMetricsCell } from './timing-metrics-cell'
+import { useUsageLogsContext } from './usage-logs-provider'
const logTypeRowTint: Record = {
[LOG_TYPE_ENUM.ERROR]:
@@ -117,7 +127,7 @@ function SummaryField({
valueClassName,
primaryOnly = false,
}: {
- label: string
+ label?: string
cell?: Cell
className?: string
valueClassName?: string
@@ -129,9 +139,11 @@ function SummaryField({
-
- {label}
-
+ {label != null && label !== '' && (
+
+ {label}
+
+ )}
+ -
+
+ )
+ }
+
+ const other = parseLogOther(log.other)
+ const cacheReadTokens = other?.cache_tokens || 0
+ const cacheWrite5m = other?.cache_creation_tokens_5m || 0
+ const cacheWrite1h = other?.cache_creation_tokens_1h || 0
+ const hasSplitCache = cacheWrite5m > 0 || cacheWrite1h > 0
+ const cacheWriteTokens = hasSplitCache
+ ? cacheWrite5m + cacheWrite1h
+ : other?.cache_creation_tokens || 0
+ const showCache = cacheReadTokens > 0 || cacheWriteTokens > 0
+
+ return (
+
+
+
+ {promptTokens.toLocaleString()} / {completionTokens.toLocaleString()}
+
+ {showCache ? (
+
+ {cacheReadTokens > 0 && (
+
+ {t('Cache')}↓ {cacheReadTokens.toLocaleString()}
+
+ )}
+ {cacheWriteTokens > 0 && (
+ ↑ {cacheWriteTokens.toLocaleString()}
+ )}
+
+ ) : (
+
+ —
+
+ )}
+
+
+ )
+}
+
+/** Mobile-only User block: own layout so avatar/name always line up on the same baseline. */
+function MobileUserField({ log }: { log: UsageLog }) {
+ const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } =
+ useUsageLogsContext()
+
+ if (!log.username) return null
+
+ return (
+ {
+ e.stopPropagation()
+ setSelectedUserId(log.user_id)
+ setUserInfoDialogOpen(true)
+ }}
+ >
+
+
+ {sensitiveVisible ? getUserAvatarFallback(log.username) : '•'}
+
+
+
+ {sensitiveVisible ? log.username : '••••'}
+
+
+ )
+}
+
+/** Merge stream badge + TPS with first-token / duration on one row. */
+function MobileStreamTimingField({ log }: { log: UsageLog }) {
+ if (!isTimingLogType(log.type)) return null
+
+ const other = parseLogOther(log.other)
+ const useTime = log.use_time || 0
+ const tokensPerSecond =
+ useTime > 0 && log.completion_tokens > 0
+ ? log.completion_tokens / useTime
+ : null
+
+ return (
+
+
+
+
+ )
+}
+
function CommonLogsCard({
cells,
}: {
@@ -184,9 +317,7 @@ function CommonLogsCard({
const modelCell = cells.get('model_name')
const quotaCell = cells.get('quota')
- const rowData = cells.get('created_at')?.row.original as
- | Record
- | undefined
+ const rowData = cells.get('created_at')?.row.original as UsageLog | undefined
return (
@@ -198,42 +329,36 @@ function CommonLogsCard({
/>
-
+
-
+ {rowData && cells.has('user') ? (
+
+ ) : (
+
+ )}
-
-
-
+ {rowData ? (
+
+ ) : (
+
+ )}
+ {rowData ? (
+
+ ) : (
+
+ )}
void
@@ -32,6 +36,8 @@ interface UsageLogsContextValue {
setAffinityDialogOpen: (open: boolean) => void
sensitiveVisible: boolean
setSensitiveVisible: (visible: boolean) => void
+ viewScope: LogsViewScope
+ setViewScope: (scope: LogsViewScope) => void
}
const UsageLogsContext = createContext(
@@ -45,6 +51,7 @@ export function UsageLogsProvider({ children }: { children: ReactNode }) {
useState(null)
const [affinityDialogOpen, setAffinityDialogOpen] = useState(false)
const [sensitiveVisible, setSensitiveVisible] = useState(true)
+ const [viewScope, setViewScope] = useState('all')
return (
{children}
@@ -73,3 +82,23 @@ export function useUsageLogsContext() {
}
return context
}
+
+/**
+ * Resolves the effective admin scope for usage logs: whether the current
+ * user is allowed to view all users' logs (`canManageScope`), and whether
+ * their current view preference (`viewScope`) has that scope active
+ * (`isAdminView`). Data fetching and admin-only UI should key off
+ * `isAdminView` rather than raw role, so an admin who switches to "only
+ * mine" is treated exactly like a regular user for that view.
+ */
+export function useLogsViewScope() {
+ const canManageScope = useIsAdmin()
+ const { viewScope, setViewScope } = useUsageLogsContext()
+
+ return {
+ canManageScope,
+ viewScope,
+ setViewScope,
+ isAdminView: canManageScope && viewScope === 'all',
+ }
+}
diff --git a/web/default/src/features/usage-logs/components/usage-logs-table.tsx b/web/default/src/features/usage-logs/components/usage-logs-table.tsx
index 03337c50c4..bb961239a9 100644
--- a/web/default/src/features/usage-logs/components/usage-logs-table.tsx
+++ b/web/default/src/features/usage-logs/components/usage-logs-table.tsx
@@ -28,7 +28,6 @@ import {
useDataTable,
} from '@/components/data-table'
import { useMediaQuery } from '@/hooks'
-import { useIsAdmin } from '@/hooks/use-admin'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { cn } from '@/lib/utils'
@@ -44,6 +43,7 @@ import type { LogCategory } from '../types'
import { CommonLogsFilterBar } from './common-logs-filter-bar'
import { TaskLogsFilterBar } from './task-logs-filter-bar'
import { UsageLogsMobileList } from './usage-logs-mobile-card'
+import { useLogsViewScope } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section')
@@ -74,7 +74,7 @@ interface UsageLogsTableProps {
export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
const { t } = useTranslation()
- const isAdmin = useIsAdmin()
+ const { isAdminView: isAdmin } = useLogsViewScope()
const isMobile = useMediaQuery('(max-width: 640px)')
const searchParams = route.useSearch()
diff --git a/web/default/src/features/usage-logs/index.tsx b/web/default/src/features/usage-logs/index.tsx
index 25b1e3399c..20467b7113 100644
--- a/web/default/src/features/usage-logs/index.tsx
+++ b/web/default/src/features/usage-logs/index.tsx
@@ -28,7 +28,9 @@ import { useSidebarConfig } from '@/hooks/use-sidebar-config'
import { UserInfoDialog } from './components/dialogs/user-info-dialog'
import {
+ type LogsViewScope,
UsageLogsProvider,
+ useLogsViewScope,
useUsageLogsContext,
} from './components/usage-logs-provider'
import { UsageLogsTable } from './components/usage-logs-table'
@@ -69,6 +71,7 @@ function UsageLogsContent() {
affinityDialogOpen,
setAffinityDialogOpen,
} = useUsageLogsContext()
+ const { canManageScope, viewScope, setViewScope } = useLogsViewScope()
const tabNavGroups = useMemo(
() => [
{
@@ -105,6 +108,15 @@ function UsageLogsContent() {
[navigate]
)
+ const handleViewScopeChange = useCallback(
+ (scope: string) => {
+ if (scope === 'all' || scope === 'self') {
+ setViewScope(scope as LogsViewScope)
+ }
+ },
+ [setViewScope]
+ )
+
const pageMeta =
activeCategory === 'common' ? SECTION_META.common : SECTION_META.task
const showTaskSwitcher =
@@ -116,6 +128,16 @@ function UsageLogsContent() {
{t(pageMeta.titleKey)}
+ {canManageScope && (
+
+
+
+ {t('All')}
+ {t('Only Mine')}
+
+
+
+ )}
{showTaskSwitcher && (
diff --git a/web/default/src/features/wallet/components/affiliate-rewards-card.tsx b/web/default/src/features/wallet/components/affiliate-rewards-card.tsx
index 024d56c570..e1824d3012 100644
--- a/web/default/src/features/wallet/components/affiliate-rewards-card.tsx
+++ b/web/default/src/features/wallet/components/affiliate-rewards-card.tsx
@@ -22,6 +22,7 @@ import { useTranslation } from 'react-i18next'
import { CopyButton } from '@/components/copy-button'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
+import { IconBadge } from '@/components/ui/icon-badge'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { formatQuota } from '@/lib/format'
@@ -65,9 +66,9 @@ export function AffiliateRewardsCard({
-
-
-
+
+
+
{t('Referral Program')}
diff --git a/web/default/src/features/wallet/components/recharge-form-card.tsx b/web/default/src/features/wallet/components/recharge-form-card.tsx
index 5ca6cb1d3b..43b3630389 100644
--- a/web/default/src/features/wallet/components/recharge-form-card.tsx
+++ b/web/default/src/features/wallet/components/recharge-form-card.tsx
@@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
+import { IconBadge } from '@/components/ui/icon-badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
@@ -120,7 +121,7 @@ export function RechargeFormCard({
const handleAmountChange = (value: string) => {
setLocalAmount(value)
- const numValue = parseInt(value) || 0
+ const numValue = Number.parseInt(value) || 0
if (numValue >= 0) {
onTopupAmountChange(numValue)
}
@@ -152,9 +153,11 @@ export function RechargeFormCard({
- {Array.from({ length: 8 }).map((_, i) => (
-
- ))}
+ {Array.from({ length: 8 }, (_, index) => `preset-${index}`).map(
+ (key) => (
+
+ )
+ )}
@@ -168,8 +171,8 @@ export function RechargeFormCard({
- {Array.from({ length: 3 }).map((_, i) => (
-
+ {['primary', 'secondary', 'tertiary'].map((key) => (
+
))}
@@ -193,6 +196,7 @@ export function RechargeFormCard({
title={t('Add Funds')}
description={t('Choose an amount and payment method')}
icon={ }
+ iconTone='success'
disableHoverEffect
action={
onOpenBilling ? (
@@ -220,7 +224,7 @@ export function RechargeFormCard({
{t('Amount')}
- {presetAmounts.map((preset, index) => {
+ {presetAmounts.map((preset) => {
const discount =
preset.discount ||
topupInfo?.discount?.[preset.value] ||
@@ -238,7 +242,7 @@ export function RechargeFormCard({
)
return (
-
+
{disabledReason}
@@ -373,7 +377,8 @@ export function RechargeFormCard({
)
})}
- ) : hasWaffoPaymentMethods ? null : (
+ ) : null}
+ {!hasStandardPaymentMethods && !hasWaffoPaymentMethods && (
{t(
@@ -394,6 +399,7 @@ export function RechargeFormCard({
{waffoPayMethods?.map((method, index) => {
const loadingKey = `waffo-${index}`
+ const methodKey = `${method.payMethodType ?? 'unknown'}-${method.payMethodName ?? method.name}`
const waffoMin = waffoMinTopup || 0
const belowMin = waffoMin > topupAmount
const disabledReason = belowMin
@@ -405,9 +411,24 @@ export function RechargeFormCard({
? `${t('Minimum:')} ${waffoMin}`
: undefined
+ let methodIcon = getPaymentIcon('waffo')
+ if (paymentLoading === loadingKey) {
+ methodIcon = (
+
+ )
+ } else if (method.icon) {
+ methodIcon = (
+
+ )
+ }
+
const button = (
onWaffoMethodSelect(method, index)}
disabled={belowMin || !!paymentLoading}
@@ -419,17 +440,7 @@ export function RechargeFormCard({
}
className='min-h-14 min-w-0 justify-start gap-2 rounded-lg px-3 py-2 text-left'
>
- {paymentLoading === loadingKey ? (
-
- ) : method.icon ? (
-
- ) : (
- getPaymentIcon('waffo')
- )}
+ {methodIcon}
{method.name}
@@ -444,9 +455,9 @@ export function RechargeFormCard({
)
return belowMin ? (
-
+
-
+
{disabledReason}
@@ -490,7 +501,9 @@ export function RechargeFormCard({
{redemptionEnabled ? (
-
+
+
+
- {Array.from({ length: 3 }).map((_, i) => (
-
+ {['first', 'second', 'third'].map((key) => (
+
))}
@@ -263,6 +263,7 @@ export function SubscriptionPlansCard({
title={t('Subscription Plans')}
description={t('Subscribe to a plan for model access')}
icon={ }
+ iconTone='warning'
disableHoverEffect
contentClassName='space-y-4 sm:space-y-5'
>
@@ -411,6 +412,38 @@ export function SubscriptionPlansCard({
const isCancelled = subscription?.status === 'cancelled'
const isActive =
subscription?.status === 'active' && !isExpired
+ const nextResetTime = subscription?.next_reset_time ?? 0
+ let statusBadge = (
+
+ )
+ if (isActive) {
+ statusBadge = (
+
+ )
+ } else if (isCancelled) {
+ statusBadge = (
+
+ )
+ }
+
+ let endTimeLabel = t('Expired at')
+ if (isActive) {
+ endTimeLabel = t('Until')
+ } else if (isCancelled) {
+ endTimeLabel = t('Cancelled at')
+ }
return (
- {isActive ? (
-
- ) : isCancelled ? (
-
- ) : (
-
- )}
+ {statusBadge}
{isActive && (
@@ -453,21 +468,15 @@ export function SubscriptionPlansCard({
)}
- {isActive
- ? t('Until')
- : isCancelled
- ? t('Cancelled at')
- : t('Expired at')}{' '}
+ {endTimeLabel}{' '}
{new Date(
(subscription?.end_time || 0) * 1000
).toLocaleString()}
- {isActive && (subscription?.next_reset_time ?? 0) > 0 && (
+ {isActive && nextResetTime > 0 && (
{t('Next reset')}:{' '}
- {new Date(
- subscription!.next_reset_time! * 1000
- ).toLocaleString()}
+ {new Date(nextResetTime * 1000).toLocaleString()}
)}
diff --git a/web/default/src/features/wallet/components/wallet-stats-card.tsx b/web/default/src/features/wallet/components/wallet-stats-card.tsx
index 3a520d693c..74ff0a834d 100644
--- a/web/default/src/features/wallet/components/wallet-stats-card.tsx
+++ b/web/default/src/features/wallet/components/wallet-stats-card.tsx
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { Activity, BarChart3, WalletCards } from 'lucide-react'
import { useTranslation } from 'react-i18next'
+import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { formatQuota } from '@/lib/format'
@@ -33,62 +34,69 @@ export function WalletStatsCard(props: WalletStatsCardProps) {
const { t } = useTranslation()
if (props.loading) {
return (
-
-
- {Array.from({ length: 3 }).map((_, i) => (
-
-
-
-
-
- ))}
-
+
+ {['balance', 'usage', 'requests'].map((key) => (
+
+
+
+
+
+ ))}
)
}
- const stats = [
+ const stats: {
+ label: string
+ value: string
+ description: string
+ icon: typeof WalletCards
+ tone: IconBadgeTone
+ }[] = [
{
label: t('Current Balance'),
value: formatQuota(props.user?.quota ?? 0),
description: t('Remaining quota'),
icon: WalletCards,
+ tone: 'success',
},
{
label: t('Total Usage'),
value: formatQuota(props.user?.used_quota ?? 0),
description: t('Total consumed quota'),
icon: BarChart3,
+ tone: 'info',
},
{
label: t('API Requests'),
value: (props.user?.request_count ?? 0).toLocaleString(),
description: t('Total requests made'),
icon: Activity,
+ tone: 'chart-4',
},
]
return (
-
-
- {stats.map((item) => (
-
-
-
-
- {item.value}
-
-
- {item.description}
+
+ {stats.map((item) => (
+
+
+
+ {item.value}
+
+
+ {item.description}
+
+
+ ))}
)
}
diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json
index 6c03de7b46..ee31249093 100644
--- a/web/default/src/i18n/locales/en.json
+++ b/web/default/src/i18n/locales/en.json
@@ -3049,6 +3049,7 @@
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "Only configured combinations are overridden. All other calls keep the token group base ratio.",
"Only enabled parameters are sent with the request.": "Only enabled parameters are sent with the request.",
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.",
+ "Only Mine": "Only Mine",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.",
"Only successful requests": "Only successful requests",
"Only successful requests count toward this limit.": "Only successful requests count toward this limit.",
diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json
index 175a36892e..20c7b54b51 100644
--- a/web/default/src/i18n/locales/fr.json
+++ b/web/default/src/i18n/locales/fr.json
@@ -3049,6 +3049,7 @@
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "Seules les combinaisons configurées sont remplacées. Les autres appels conservent le ratio de base du groupe du jeton.",
"Only enabled parameters are sent with the request.": "Seuls les paramètres activés sont envoyés avec la requête.",
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Saisissez uniquement l’origine du site, par exemple https://api.example.com. N’ajoutez aucun chemin comme /api/user/epay/notify. Laissez vide pour utiliser l’adresse du serveur.",
+ "Only Mine": "Uniquement les miens",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Seuls les champs sélectionnés seront écrasés. Vous pouvez relancer l'assistant de synchronisation si de nouveaux conflits apparaissent.",
"Only successful requests": "Uniquement les requêtes réussies",
"Only successful requests count toward this limit.": "Seules les requêtes réussies comptent pour cette limite.",
diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json
index 2fbdfe22d2..715ff04b6a 100644
--- a/web/default/src/i18n/locales/ja.json
+++ b/web/default/src/i18n/locales/ja.json
@@ -3049,6 +3049,7 @@
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "設定済みの組み合わせだけが上書きされます。他の呼び出しはトークングループの基本倍率を維持します。",
"Only enabled parameters are sent with the request.": "有効なパラメータだけがリクエストに送信されます。",
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "サイトのオリジンのみを入力してください。例: https://api.example.com。/api/user/epay/notify などのパスは含めないでください。空欄の場合はサーバーアドレスを使用します。",
+ "Only Mine": "自分のみ",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "選択されたフィールドのみが上書きされます。新しい競合が発生した場合は、同期ウィザードを再実行できます。",
"Only successful requests": "成功したリクエストのみ",
"Only successful requests count toward this limit.": "成功したリクエストのみがこの制限にカウントされます。",
diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json
index a99e9636c1..97c7e494ca 100644
--- a/web/default/src/i18n/locales/ru.json
+++ b/web/default/src/i18n/locales/ru.json
@@ -3049,6 +3049,7 @@
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "Переопределяются только настроенные комбинации. Остальные вызовы используют базовый коэффициент группы токена.",
"Only enabled parameters are sent with the request.": "С запросом отправляются только включенные параметры.",
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Введите только origin сайта, например https://api.example.com. Не добавляйте пути, например /api/user/epay/notify. Оставьте пустым, чтобы использовать адрес сервера.",
+ "Only Mine": "Только мои",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Будут перезаписаны только выбранные поля. Вы можете повторно запустить мастер синхронизации, если появятся новые конфликты.",
"Only successful requests": "Только успешные запросы",
"Only successful requests count toward this limit.": "Только успешные запросы учитываются в этом лимите.",
diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json
index 3708edef1e..39c0340ce2 100644
--- a/web/default/src/i18n/locales/vi.json
+++ b/web/default/src/i18n/locales/vi.json
@@ -3049,6 +3049,7 @@
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "Chỉ các tổ hợp đã cấu hình mới bị ghi đè. Các lệnh gọi khác giữ tỷ lệ cơ bản của nhóm token.",
"Only enabled parameters are sent with the request.": "Chỉ các tham số đã bật mới được gửi trong yêu cầu.",
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Chỉ nhập origin của trang, ví dụ https://api.example.com. Không nhập đường dẫn như /api/user/epay/notify. Để trống để dùng địa chỉ máy chủ.",
+ "Only Mine": "Chỉ của tôi",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Chỉ các trường được chọn sẽ bị ghi đè. Bạn có thể chạy lại trình hướng dẫn đồng bộ hóa nếu có xung đột mới xuất hiện.",
"Only successful requests": "Chỉ các yêu cầu thành công",
"Only successful requests count toward this limit.": "Chỉ những yêu cầu thành công mới được tính vào giới hạn này.",
diff --git a/web/default/src/i18n/locales/zh-TW.json b/web/default/src/i18n/locales/zh-TW.json
index aa7eccc3f1..8e1cb63741 100644
--- a/web/default/src/i18n/locales/zh-TW.json
+++ b/web/default/src/i18n/locales/zh-TW.json
@@ -3049,6 +3049,7 @@
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "只有已設定的組合會被覆蓋,其他呼叫仍使用令牌分組的基礎倍率。",
"Only enabled parameters are sent with the request.": "只有啟用的參數會隨請求傳送。",
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "只填寫站點根域名,例如 https://api.example.com。不要填寫 /api/user/epay/notify 這類路徑。留空則使用伺服器地址。",
+ "Only Mine": "僅自己",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "僅選定的欄位將會被覆蓋。如果出現新的衝突,您可以重新執行同步精靈。",
"Only successful requests": "僅成功的請求",
"Only successful requests count toward this limit.": "僅成功的請求計入此限制。",
@@ -3266,7 +3267,7 @@
"Per 1M tokens": "每 1M tokens",
"per request": "每次請求",
"Per request": "每次請求",
- "Per Request": "按請求",
+ "Per Request": "按次計費",
"Per-call": "每次呼叫",
"Per-feature metered windows split by model or capability.": "按模型或能力拆分的附加收費能力窗口。",
"Per-group performance": "各分組效能",
diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json
index 6be0ff7a45..ddf55d799d 100644
--- a/web/default/src/i18n/locales/zh.json
+++ b/web/default/src/i18n/locales/zh.json
@@ -3049,6 +3049,7 @@
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "只有已配置的组合会被覆盖,其他调用仍使用令牌分组的基础倍率。",
"Only enabled parameters are sent with the request.": "只有启用的参数会随请求发送。",
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "只填写站点根域名,例如 https://api.example.com。不要填写 /api/user/epay/notify 这类路径。留空则使用服务器地址。",
+ "Only Mine": "仅自己",
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "仅选定的字段将被覆盖。如果出现新的冲突,您可以重新运行同步向导。",
"Only successful requests": "仅成功的请求",
"Only successful requests count toward this limit.": "仅成功的请求计入此限制。",
@@ -3266,7 +3267,7 @@
"Per 1M tokens": "每 1M tokens",
"per request": "每次请求",
"Per request": "每次请求",
- "Per Request": "按请求",
+ "Per Request": "按次计费",
"Per-call": "每次调用",
"Per-feature metered windows split by model or capability.": "按模型或能力拆分的附加计费能力窗口。",
"Per-group performance": "各分组性能",
diff --git a/web/default/src/lib/avatar.ts b/web/default/src/lib/avatar.ts
index a50be09ebe..f2c28203ab 100644
--- a/web/default/src/lib/avatar.ts
+++ b/web/default/src/lib/avatar.ts
@@ -35,7 +35,7 @@ export function getUserAvatarStyle(name: string): UserAvatarStyle {
const lightness = 52 + ((hash >> 4) % 8)
return {
- backgroundColor: `hsl(${hue} ${saturation}% ${lightness}% / 0.82)`,
+ backgroundColor: `hsl(${hue} ${saturation}% ${lightness}%)`,
color: 'white',
}
}
diff --git a/web/default/src/lib/theme-customization.ts b/web/default/src/lib/theme-customization.ts
index b668033845..c8d92abada 100644
--- a/web/default/src/lib/theme-customization.ts
+++ b/web/default/src/lib/theme-customization.ts
@@ -27,7 +27,7 @@ export const THEME_PRESETS = [
{
value: 'default',
name: 'Default',
- swatches: ['oklch(0.13 0 0)', 'oklch(0.95 0 0)'],
+ swatches: ['oklch(0.72 0.18 250)', 'oklch(0.7 0.12 280)'],
},
{
// Inspired by Anthropic's official brand language: warm cream canvas
diff --git a/web/default/src/styles/theme.css b/web/default/src/styles/theme.css
index fc78414905..8acb4dee68 100644
--- a/web/default/src/styles/theme.css
+++ b/web/default/src/styles/theme.css
@@ -109,13 +109,13 @@ For commercial licensing, please contact support@quantumnous.com
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
- --primary: oklch(0.13 0 0);
- --primary-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.58 0.2 250);
+ --primary-foreground: oklch(1 0 0);
--secondary: oklch(0.95 0 0);
- --secondary-foreground: oklch(0.145 0 0);
+ --secondary-foreground: oklch(0.42 0.16 250);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.49 0 0);
- --accent: oklch(0.95 0 0);
+ --accent: color-mix(in oklch, var(--primary) 12%, var(--background));
--accent-foreground: oklch(0.145 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
@@ -129,23 +129,23 @@ For commercial licensing, please contact support@quantumnous.com
--neutral-foreground: oklch(0.145 0 0);
--border: oklch(0.93 0 0);
--input: oklch(0.93 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);
+ --ring: oklch(0.58 0.2 250);
+ --chart-1: oklch(0.72 0.18 250);
+ --chart-2: oklch(0.65 0.15 200);
+ --chart-3: oklch(0.7 0.12 280);
+ --chart-4: oklch(0.68 0.19 325);
+ --chart-5: oklch(0.68 0.16 155);
--overview-accent-1: oklch(0.72 0.18 250);
--overview-accent-2: oklch(0.65 0.15 200);
--overview-accent-3: oklch(0.7 0.12 280);
--sidebar: color-mix(in oklch, var(--foreground) 0.5%, var(--background));
--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-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.58 0.2 250);
+ --sidebar-primary-foreground: oklch(1 0 0);
+ --sidebar-accent: color-mix(in oklch, var(--primary) 12%, var(--background));
+ --sidebar-accent-foreground: oklch(0.42 0.16 250);
--sidebar-border: oklch(0.93 0 0);
- --sidebar-ring: oklch(0.708 0 0);
+ --sidebar-ring: oklch(0.58 0.2 250);
--skeleton-base: oklch(0.97 0 0);
--skeleton-highlight: oklch(0.985 0 0);
--table-row: var(--background);
@@ -159,19 +159,17 @@ For commercial licensing, please contact support@quantumnous.com
var(--foreground) 3%,
var(--background)
);
- --table-disabled: color-mix(
- in oklch,
- var(--foreground) 5.5%,
- var(--background)
- );
+ /* Disabled rows: kept subtle so they read as "quiet", not as a colored
+ alert state. */
+ --table-disabled: color-mix(in oklch, var(--foreground) 3%, var(--background));
--table-disabled-hover: color-mix(
in oklch,
- var(--foreground) 7%,
+ var(--foreground) 4.5%,
var(--background)
);
--table-disabled-border: color-mix(
in oklch,
- var(--foreground) 24%,
+ var(--foreground) 16%,
var(--background)
);
}
@@ -184,13 +182,13 @@ For commercial licensing, please contact support@quantumnous.com
--card-foreground: oklch(0.965 0 0);
--popover: oklch(0.305 0 0);
--popover-foreground: oklch(0.965 0 0);
- --primary: oklch(0.965 0 0);
- --primary-foreground: oklch(0.155 0 0);
+ --primary: oklch(0.66 0.17 250);
+ --primary-foreground: oklch(1 0 0);
--secondary: oklch(0.335 0 0);
- --secondary-foreground: oklch(0.965 0 0);
+ --secondary-foreground: oklch(0.9 0.05 250);
--muted: oklch(0.305 0 0);
--muted-foreground: oklch(0.78 0 0);
- --accent: oklch(0.365 0 0);
+ --accent: color-mix(in oklch, var(--primary) 20%, var(--background));
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
@@ -204,20 +202,20 @@ For commercial licensing, please contact support@quantumnous.com
--neutral-foreground: oklch(0.155 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 17%);
- --ring: oklch(0.68 0 0);
- --chart-1: oklch(0.488 0.243 264.376);
- --chart-2: oklch(0.696 0.17 162.48);
- --chart-3: oklch(0.769 0.188 70.08);
- --chart-4: oklch(0.627 0.265 303.9);
- --chart-5: oklch(0.645 0.246 16.439);
+ --ring: oklch(0.66 0.17 250);
+ --chart-1: oklch(0.76 0.14 250);
+ --chart-2: oklch(0.76 0.13 200);
+ --chart-3: oklch(0.76 0.13 285);
+ --chart-4: oklch(0.75 0.16 325);
+ --chart-5: oklch(0.76 0.14 155);
--sidebar: oklch(0.225 0 0);
--sidebar-foreground: oklch(0.95 0 0);
- --sidebar-primary: oklch(0.965 0 0);
- --sidebar-primary-foreground: oklch(0.155 0 0);
- --sidebar-accent: oklch(0.355 0 0);
- --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.66 0.17 250);
+ --sidebar-primary-foreground: oklch(1 0 0);
+ --sidebar-accent: color-mix(in oklch, var(--primary) 20%, var(--background));
+ --sidebar-accent-foreground: oklch(0.9 0.05 250);
--sidebar-border: oklch(1 0 0 / 11%);
- --sidebar-ring: oklch(0.68 0 0);
+ --sidebar-ring: oklch(0.66 0.17 250);
--skeleton-base: oklch(0.335 0 0);
--skeleton-highlight: oklch(0.44 0 0);
--table-row: var(--background);
@@ -227,19 +225,15 @@ For commercial licensing, please contact support@quantumnous.com
var(--foreground) 9%,
var(--background)
);
- --table-disabled: color-mix(
- in oklch,
- var(--foreground) 10%,
- var(--background)
- );
+ --table-disabled: color-mix(in oklch, var(--foreground) 7%, var(--background));
--table-disabled-hover: color-mix(
in oklch,
- var(--foreground) 13%,
+ var(--foreground) 9.5%,
var(--background)
);
--table-disabled-border: color-mix(
in oklch,
- var(--foreground) 34%,
+ var(--foreground) 24%,
var(--background)
);
}