@@ -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 997f74f4..9fa2d0f2 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 ae5e7e1e..1196e8d5 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 e67085e3..60ed416d 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 00000000..1a6349da
--- /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 dce04c1c..391d64cf 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 4f2dd177..1a77ce1c 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 b1af4e02..b0c6f1bd 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 5219c09a..dd754e22 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 01bdf558..e6520268 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 9b19a6d5..d38465b3 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 40937818..cf737e12 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 a9364fee..cb28966b 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 03337c50..bb961239 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 25b1e339..20467b71 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 024d56c5..e1824d30 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 5ca6cb1d..43b36303 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 3a520d69..74ff0a83 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 6c03de7b..ee312490 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 175a3689..20c7b54b 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 2fbdfe22..715ff04b 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 a99e9636..97c7e494 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 3708edef..39c0340c 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 aa7eccc3..8e1cb637 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 6be0ff7a..ddf55d79 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 a50be09e..f2c28203 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 b6680338..c8d92aba 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 fc784149..8acb4dee 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)
);
}