style(ui): align wallet and profile interactions

This commit is contained in:
t0ng7u
2026-06-16 00:44:33 +08:00
parent b36888ba9c
commit eb86311604
39 changed files with 341 additions and 314 deletions
@@ -16,7 +16,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { flexRender, type Header, type Table as TanstackTable } from '@tanstack/react-table'
import {
flexRender,
type Header,
type Table as TanstackTable,
} from '@tanstack/react-table'
import { TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { DataTableColumnHeader } from './column-header'
import type { DataTableColumnClassName } from './types'
@@ -344,7 +344,8 @@ function renderDesktop<TData>(
splitHeader={fixedHeight}
tableContainerClassName={fixedHeight ? 'h-full min-h-0' : undefined}
tableHeaderClassName={cn(
fixedHeight && '[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))]',
fixedHeight &&
'[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))]',
props.tableHeaderClassName
)}
getColumnClassName={props.getColumnClassName}
@@ -25,7 +25,6 @@ import {
} from '@tanstack/react-table'
import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { StatusBadgeTypeContext } from '@/components/status-badge'
import { cn } from '@/lib/utils'
import {
Empty,
@@ -35,6 +34,7 @@ import {
EmptyTitle,
} from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
import { StatusBadgeTypeContext } from '@/components/status-badge'
interface MobileCardListProps<TData> {
table: Table<TData>
@@ -211,7 +211,10 @@ function FallbackRow<TData>({ row }: { row: Row<TData> }) {
if (!label) {
return (
<div key={cell.id} className='flex justify-end overflow-hidden [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'>
<div
key={cell.id}
className='flex justify-end overflow-hidden [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'
>
<StatusBadgeTypeContext.Provider value='text'>
{renderCellContent(cell)}
</StatusBadgeTypeContext.Provider>
@@ -22,7 +22,8 @@ export const staticDataTableClassNames = {
embeddedContainer: 'rounded-none border-0',
compactTable: 'text-sm',
compactHeaderRow: 'hover:bg-transparent',
mutedHeaderRow: '[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))] hover:[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))]',
mutedHeaderRow:
'[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))] hover:[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))]',
compactHeaderCell:
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase',
compactHeaderCellRight:
+2 -8
View File
@@ -38,20 +38,14 @@ export function ProviderBadge({
return (
<div
data-slot='provider-badge'
className={cn(
'flex min-w-0 max-w-full items-center gap-1.5',
className
)}
className={cn('flex max-w-full min-w-0 items-center gap-1.5', className)}
>
{icon && <span className='flex shrink-0 items-center'>{icon}</span>}
<StatusBadge
label={label}
autoColor={label}
size='sm'
className={cn(
'min-w-0 shrink overflow-hidden',
!icon && 'pl-0'
)}
className={cn('min-w-0 shrink overflow-hidden', !icon && 'pl-0')}
{...badgeProps}
/>
</div>
+1 -1
View File
@@ -77,7 +77,7 @@ function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
<tr
data-slot='table-row'
className={cn(
'group hover:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] has-aria-expanded:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] data-[state=selected]:bg-muted border-b transition-colors',
'group data-[state=selected]:bg-muted border-b transition-colors hover:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] has-aria-expanded:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))]',
className
)}
{...props}
+6 -1
View File
@@ -32,6 +32,7 @@ type TitledCardProps = {
icon?: ReactNode
action?: ReactNode
children?: ReactNode
disableHoverEffect?: boolean
className?: string
headerClassName?: string
contentClassName?: string
@@ -46,6 +47,7 @@ export function TitledCard({
icon,
action,
children,
disableHoverEffect,
className,
headerClassName,
contentClassName,
@@ -54,7 +56,10 @@ export function TitledCard({
descriptionClassName,
}: TitledCardProps) {
return (
<Card className={cn('gap-0 overflow-hidden py-0', className)}>
<Card
data-card-hover={disableHoverEffect ? 'false' : undefined}
className={cn('gap-0 overflow-hidden py-0', className)}
>
<CardHeader
className={cn('border-b p-3 !pb-3 sm:p-5 sm:!pb-5', headerClassName)}
>
@@ -622,7 +622,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
: undefined
return (
<div className='flex min-w-0 max-w-full items-center gap-2 overflow-hidden'>
<div className='flex max-w-full min-w-0 items-center gap-2 overflow-hidden'>
{isMultiKey && (
<TooltipProvider delay={100}>
<Tooltip>
@@ -641,7 +641,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
<Tooltip>
<TooltipTrigger
render={
<div className='min-w-0 max-w-full overflow-hidden' />
<div className='max-w-full min-w-0 overflow-hidden' />
}
>
<ProviderBadge
@@ -649,7 +649,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
label={typeName}
copyable={false}
showDot={false}
className='min-w-0 max-w-full overflow-hidden'
className='max-w-full min-w-0 overflow-hidden'
/>
</TooltipTrigger>
<TooltipContent side='top'>{typeName}</TooltipContent>
@@ -895,7 +895,14 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
if (!tag)
return <span className='text-muted-foreground text-xs'>-</span>
return <StatusBadge label={tag} autoColor={tag} size='sm' className='-ml-1.5' />
return (
<StatusBadge
label={tag}
autoColor={tag}
size='sm'
className='-ml-1.5'
/>
)
},
size: 120,
enableSorting: false,
@@ -190,7 +190,7 @@ function SetupGuideBackdrop(props: { compact?: boolean }) {
/>
<div
className={cn(
'text-foreground/5 pointer-events-none absolute inset-y-0 right-0 hidden overflow-hidden font-mono sm:block dark:text-foreground/8',
'text-foreground/5 dark:text-foreground/8 pointer-events-none absolute inset-y-0 right-0 hidden overflow-hidden font-mono sm:block',
props.compact ? 'w-1/2 opacity-45' : 'w-[58%] opacity-75'
)}
aria-hidden='true'
@@ -589,7 +589,9 @@ export function OverviewDashboard() {
model,
keyName,
keyId: preferredKey?.id,
displayKey: preferredKey ? formatDisplayKey(`sk-${preferredKey.key}`) : 'sk-...',
displayKey: preferredKey
? formatDisplayKey(`sk-${preferredKey.key}`)
: 'sk-...',
ready,
}
}, [apiInfoItems, modelsQuery.data, preferredKey, t])
@@ -64,77 +64,77 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
return (
<div className='-ml-2'>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant='ghost'
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
/>
}
>
<MoreHorizontal className='h-4 w-4' />
<span className='sr-only'>{t('Open menu')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
{/* Edit */}
<DropdownMenuItem onClick={handleEdit}>
{t('Edit')}
<DropdownMenuShortcut>
<Pencil size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Enable/Disable */}
<DropdownMenuItem onClick={handleToggleStatus}>
{isEnabled ? (
<>
{t('Disable')}
<DropdownMenuShortcut>
<PowerOff size={16} />
</DropdownMenuShortcut>
</>
) : (
<>
{t('Enable')}
<DropdownMenuShortcut>
<Power size={16} />
</DropdownMenuShortcut>
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Delete */}
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setDeleteConfirmOpen(true)
}}
className='text-destructive focus:text-destructive'
<DropdownMenuTrigger
render={
<Button
variant='ghost'
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
/>
}
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
<MoreHorizontal className='h-4 w-4' />
<span className='sr-only'>{t('Open menu')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
{/* Edit */}
<DropdownMenuItem onClick={handleEdit}>
{t('Edit')}
<DropdownMenuShortcut>
<Pencil size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<ConfirmDialog
open={deleteConfirmOpen}
onOpenChange={setDeleteConfirmOpen}
title={t('Delete Model')}
desc={`Are you sure you want to delete "${model.model_name}"? This action cannot be undone.`}
confirmText='Delete'
destructive
handleConfirm={() => {
handleDeleteModel(model.id, queryClient)
setDeleteConfirmOpen(false)
}}
/>
</DropdownMenu>
<DropdownMenuSeparator />
{/* Enable/Disable */}
<DropdownMenuItem onClick={handleToggleStatus}>
{isEnabled ? (
<>
{t('Disable')}
<DropdownMenuShortcut>
<PowerOff size={16} />
</DropdownMenuShortcut>
</>
) : (
<>
{t('Enable')}
<DropdownMenuShortcut>
<Power size={16} />
</DropdownMenuShortcut>
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Delete */}
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setDeleteConfirmOpen(true)
}}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
<ConfirmDialog
open={deleteConfirmOpen}
onOpenChange={setDeleteConfirmOpen}
title={t('Delete Model')}
desc={`Are you sure you want to delete "${model.model_name}"? This action cannot be undone.`}
confirmText='Delete'
destructive
handleConfirm={() => {
handleDeleteModel(model.id, queryClient)
setDeleteConfirmOpen(false)
}}
/>
</DropdownMenu>
</div>
)
}
@@ -322,13 +322,11 @@ export function DynamicPricingBreakdown({
className: 'text-muted-foreground py-2 font-medium',
cellClassName: 'py-2.5 align-top',
cell: (tier) => {
const condSummary = formatConditionSummary(
tier.conditions,
t
)
const condSummary = formatConditionSummary(tier.conditions, t)
const isMatched =
normalizedMatchedTierLabel !== '' &&
normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
normalizeTierLabel(tier.label) ===
normalizedMatchedTierLabel
return (
<>
<div className='flex flex-wrap items-center gap-1.5'>
+1 -3
View File
@@ -57,9 +57,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
const groups = props.model.enable_groups || []
const endpoints = props.model.supported_endpoint_types || []
const modelIconKey = props.model.icon || props.model.vendor_icon
const modelIcon = modelIconKey
? getLobeIcon(modelIconKey, 28)
: null
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 28) : null
const initial = props.model.model_name?.charAt(0).toUpperCase() || '?'
const isDynamicPricing =
props.model.billing_mode === 'tiered_expr' &&
@@ -32,6 +32,7 @@ import { formatQuotaWithCurrency } from '@/lib/currency'
import dayjs from '@/lib/dayjs'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
Tooltip,
@@ -221,7 +222,7 @@ export function CheckinCalendarCard({
if (isLoading) {
return (
<div className='bg-card overflow-hidden rounded-2xl border'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<div className='p-6'>
<div className='flex items-start justify-between gap-4'>
<div className='flex items-center gap-3'>
@@ -234,7 +235,7 @@ export function CheckinCalendarCard({
<Skeleton className='h-9 w-28 rounded-md' />
</div>
</div>
</div>
</Card>
)
}
@@ -270,14 +271,13 @@ export function CheckinCalendarCard({
</div>
</Dialog>
<div className='bg-card overflow-hidden rounded-2xl border'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
{/* Header */}
<div className='border-b p-4 sm:p-6'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4'>
<Button
<button
type='button'
variant='ghost'
className='flex h-auto min-w-0 flex-1 items-start gap-3 p-0 text-left whitespace-normal hover:bg-transparent'
className='flex min-w-0 flex-1 items-start gap-3 rounded-lg text-left whitespace-normal outline-none'
onClick={() => setCollapsed((v) => !v)}
>
<div className='bg-primary/10 text-primary flex h-10 w-10 shrink-0 items-center justify-center rounded-xl sm:h-11 sm:w-11'>
@@ -311,7 +311,7 @@ export function CheckinCalendarCard({
: t('Check in daily to receive random quota rewards')}
</p>
</div>
</Button>
</button>
<Button
onClick={() => doCheckin()}
disabled={checkinLoading || checkedToday}
@@ -423,7 +423,6 @@ export function CheckinCalendarCard({
'relative flex h-9 w-full flex-col items-center justify-center rounded-lg px-0 text-xs font-medium sm:h-10 sm:text-sm',
!dayObj.isCurrentMonth &&
'text-muted-foreground/40 cursor-default',
isToday && 'hover:bg-primary/90',
!isToday && isCheckedIn && 'font-semibold'
)}
>
@@ -476,7 +475,7 @@ export function CheckinCalendarCard({
</div>
</>
) : null}
</div>
</Card>
</TooltipProvider>
)
}
@@ -105,6 +105,7 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
title={t('Language Preferences')}
description={t('Set the language used across the interface')}
icon={<Languages className='h-4 w-4' />}
disableHoverEffect
>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4'>
<div className='space-y-1'>
@@ -187,7 +187,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
if (pageLoading || loading) {
return (
<Card className='gap-0 overflow-hidden py-0'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='p-3 sm:p-5'>
<Skeleton className='h-6 w-48' />
<Skeleton className='mt-2 h-4 w-64' />
@@ -208,7 +208,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
return (
<>
<Card className='gap-0 overflow-hidden py-0'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='p-3 sm:p-5'>
<CardTitle className='text-lg tracking-tight sm:text-xl'>
{t('Passkey Login')}
@@ -310,7 +310,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
{t('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
className='bg-destructive text-destructive-foreground'
disabled={removing}
onClick={(event) => {
event.preventDefault()
@@ -18,12 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { Activity, BarChart3, WalletCards } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { formatCompactNumber, formatQuota } from '@/lib/format'
import { getRoleLabel } from '@/lib/roles'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { StatusBadge } from '@/components/status-badge'
import { getUserInitials, getDisplayName } from '../lib'
import { getDisplayName } from '../lib'
import type { UserProfile } from '../types'
// ============================================================================
@@ -40,8 +42,8 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
if (loading) {
return (
<div className='bg-card overflow-hidden rounded-lg border'>
<div className='p-4 sm:p-5'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardContent className='p-4 sm:p-5'>
<div className='flex flex-col items-center gap-4 text-center sm:flex-row sm:text-left'>
<Skeleton className='h-16 w-16 rounded-2xl' />
<div className='space-y-3'>
@@ -56,7 +58,7 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
</div>
</div>
</div>
</div>
</CardContent>
<div className='border-t'>
<div className='divide-border/60 grid grid-cols-1 divide-y sm:grid-cols-3 sm:divide-x sm:divide-y-0'>
{Array.from({ length: 3 }).map((_, i) => (
@@ -68,14 +70,16 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
))}
</div>
</div>
</div>
</Card>
)
}
if (!profile) return null
const displayName = getDisplayName(profile)
const initials = getUserInitials(profile)
const avatarName = profile.username || displayName
const avatarFallback = getUserAvatarFallback(avatarName)
const avatarFallbackStyle = getUserAvatarStyle(avatarName)
const roleLabel = getRoleLabel(profile.role)
const stats = [
{
@@ -99,12 +103,15 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
]
return (
<div className='bg-card overflow-hidden rounded-lg border'>
<div className='p-3 sm:p-5'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardContent className='p-3 sm:p-5'>
<div className='flex items-center gap-3 text-left sm:gap-4'>
<Avatar className='ring-background h-12 w-12 rounded-xl text-sm ring-2 sm:h-16 sm:w-16 sm:rounded-2xl sm:text-lg sm:ring-4'>
<AvatarFallback className='bg-primary/10 text-primary rounded-xl sm:rounded-2xl'>
{initials}
<AvatarFallback
className='rounded-xl font-semibold text-white sm:rounded-2xl'
style={avatarFallbackStyle}
>
{avatarFallback}
</AvatarFallback>
</Avatar>
@@ -142,7 +149,7 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
</div>
</div>
</div>
</div>
</CardContent>
<div className='border-t'>
<div className='divide-border/60 grid grid-cols-3 divide-x'>
{stats.map((item) => (
@@ -164,6 +171,6 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
))}
</div>
</div>
</div>
</Card>
)
}
@@ -47,7 +47,7 @@ export function ProfileSecurityCard({
if (loading) {
return (
<Card className='gap-0 overflow-hidden py-0'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<Skeleton className='h-6 w-32' />
<Skeleton className='mt-2 h-4 w-48' />
@@ -93,6 +93,7 @@ export function ProfileSecurityCard({
title={t('Security')}
description={t('Manage your security settings and account access')}
icon={<Shield className='h-4 w-4' />}
disableHoverEffect
>
<div className='grid grid-cols-1 gap-2.5 sm:gap-3 md:grid-cols-3'>
{securityActions.map((item) => (
@@ -100,10 +101,8 @@ export function ProfileSecurityCard({
key={item.title}
type='button'
onClick={item.action}
className={`hover:bg-muted/50 flex items-center gap-3 rounded-lg border p-3 text-left transition-colors md:flex-col md:gap-2 md:p-4 md:text-center ${
item.variant === 'destructive'
? 'border-destructive/30 hover:border-destructive/50 hover:bg-destructive/5'
: ''
className={`flex items-center gap-3 rounded-lg border p-3 text-left md:flex-col md:gap-2 md:p-4 md:text-center ${
item.variant === 'destructive' ? 'border-destructive/30' : ''
}`}
>
<div
@@ -47,7 +47,7 @@ export function ProfileSettingsCard({
if (loading) {
return (
<Card className='gap-0 overflow-hidden py-0'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<Skeleton className='h-6 w-32' />
<Skeleton className='mt-2 h-4 w-48' />
@@ -67,6 +67,7 @@ export function ProfileSettingsCard({
title={t('Settings')}
description={t('Configure your account preferences and integrations')}
icon={<Settings className='h-4 w-4' />}
disableHoverEffect
>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className='grid w-full grid-cols-2 items-stretch gap-1 rounded-xl p-1 group-data-horizontal/tabs:h-10'>
@@ -200,7 +200,7 @@ export function SidebarModulesCard() {
}
return (
<Card className='gap-0 overflow-hidden py-0'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<div className='flex items-center gap-3'>
<div className='bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg sm:h-9 sm:w-9'>
@@ -240,7 +240,7 @@ export function SidebarModulesCard() {
{section.modules.map((mod) => (
<div
key={mod.key}
className={`flex min-h-16 items-center justify-between rounded-lg border p-3 transition-opacity ${
className={`flex min-h-16 items-center justify-between rounded-lg border p-3 ${
sectionEnabled ? '' : 'opacity-50'
}`}
>
@@ -350,7 +350,7 @@ export function AccountBindingsTab({
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive h-7 shrink-0 px-2.5 text-xs'
className='text-destructive h-7 shrink-0 px-2.5 text-xs'
onClick={() => setUnbindTarget(binding)}
>
<Unlink className='mr-1 h-3 w-3' />
@@ -24,8 +24,8 @@ import { ROLE } from '@/lib/roles'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Switch } from '@/components/ui/switch'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { PasswordInput } from '@/components/password-input'
import { updateUserSettings } from '../../api'
import {
@@ -35,13 +35,24 @@ import {
import { parseUserSettings } from '../../lib'
import type { UserProfile, UserSettings, NotifyType } from '../../types'
const NOTIFICATION_ICONS: Record<string, typeof Mail> = {
const NOTIFICATION_ICONS: Record<NotifyType, typeof Mail> = {
email: Mail,
webhook: Webhook,
bark: Bell,
gotify: Server,
}
const NOTIFICATION_VALUES = new Set<NotifyType>(
NOTIFICATION_METHODS.map((method) => method.value)
)
function normalizeNotifyType(value: unknown): NotifyType {
return typeof value === 'string' &&
NOTIFICATION_VALUES.has(value as NotifyType)
? (value as NotifyType)
: 'email'
}
// ============================================================================
// Settings Tab Component
// ============================================================================
@@ -82,7 +93,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
if (profile?.setting) {
const parsed = parseUserSettings(profile.setting)
setSettings({
notify_type: parsed.notify_type || 'email',
notify_type: normalizeNotifyType(parsed.notify_type),
quota_warning_threshold:
parsed.quota_warning_threshold ?? DEFAULT_QUOTA_WARNING_THRESHOLD,
notification_email: parsed.notification_email ?? '',
@@ -119,44 +130,42 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
}
}
const notifyType = normalizeNotifyType(settings.notify_type)
return (
<div className='space-y-4 sm:space-y-6'>
{/* Notification Type */}
<div className='space-y-2.5'>
<Label>{t('Notification Method')}</Label>
<RadioGroup
value={settings.notify_type}
onValueChange={(value) =>
updateField('notify_type', value as NotifyType)
}
className='grid grid-cols-4 gap-1.5 sm:gap-3'
<ToggleGroup
value={[notifyType]}
onValueChange={(value) => {
const nextValue = value.find((item) => item !== notifyType)
if (nextValue)
updateField('notify_type', normalizeNotifyType(nextValue))
}}
aria-label={t('Notification Method')}
variant='outline'
size='lg'
spacing={2}
className='grid w-full grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-3'
>
{NOTIFICATION_METHODS.map((method) => {
const Icon = NOTIFICATION_ICONS[method.value]
const isSelected = settings.notify_type === method.value
return (
<Label
<ToggleGroupItem
key={method.value}
htmlFor={method.value}
className={`flex min-h-16 cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border p-2 text-center transition-colors sm:min-h-20 sm:gap-2 sm:border-2 sm:p-3 ${
isSelected
? 'border-primary bg-primary/5 text-primary'
: 'border-muted hover:border-muted-foreground/25 hover:bg-muted/50'
}`}
value={method.value}
className='h-auto min-h-14 w-full flex-col gap-1.5 px-3 py-3 sm:min-h-16'
>
<RadioGroupItem
value={method.value}
id={method.value}
className='sr-only'
/>
<Icon className='h-4 w-4 sm:h-5 sm:w-5' />
<span className='max-w-full truncate text-xs font-medium sm:text-sm'>
{t(method.label)}
</span>
</Label>
</ToggleGroupItem>
)
})}
</RadioGroup>
</ToggleGroup>
</div>
{/* Warning Threshold */}
@@ -178,7 +187,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
</div>
{/* Email Settings */}
{settings.notify_type === 'email' && (
{notifyType === 'email' && (
<div className='space-y-1.5'>
<Label htmlFor='notifyEmail'>{t('Notification Email')}</Label>
<Input
@@ -193,7 +202,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
)}
{/* Webhook Settings */}
{settings.notify_type === 'webhook' && (
{notifyType === 'webhook' && (
<>
<div className='space-y-1.5'>
<Label htmlFor='webhookUrl'>{t('Webhook URL')}</Label>
@@ -219,7 +228,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
)}
{/* Bark Settings */}
{settings.notify_type === 'bark' && (
{notifyType === 'bark' && (
<div className='space-y-1.5'>
<Label htmlFor='barkUrl'>{t('Bark Push URL')}</Label>
<Input
@@ -237,7 +246,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
)}
{/* Gotify Settings */}
{settings.notify_type === 'gotify' && (
{notifyType === 'gotify' && (
<>
<div className='space-y-1.5'>
<Label htmlFor='gotifyUrl'>{t('Gotify Server URL')}</Label>
@@ -300,7 +309,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
href='https://gotify.net/'
target='_blank'
rel='noopener noreferrer'
className='text-primary hover:underline'
className='text-primary underline underline-offset-4'
>
{t('Gotify Documentation')}
</a>
@@ -51,7 +51,7 @@ export function TwoFACard({ loading: pageLoading }: TwoFACardProps) {
if (pageLoading || loading) {
return (
<Card className='gap-0 overflow-hidden py-0'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='p-3 sm:p-5'>
<Skeleton className='h-6 w-48' />
<Skeleton className='mt-2 h-4 w-64' />
@@ -65,7 +65,7 @@ export function TwoFACard({ loading: pageLoading }: TwoFACardProps) {
return (
<>
<Card className='gap-0 overflow-hidden py-0'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='p-3 sm:p-5'>
<CardTitle className='text-lg tracking-tight sm:text-xl'>
{t('Two-Factor Authentication')}
@@ -79,64 +79,64 @@ export function DataTableRowActions<TData>({
return (
<div className='-ml-2'>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button
variant='ghost'
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
/>
}
>
<DotsHorizontalIcon className='h-4 w-4' />
<span className='sr-only'>{t('Open menu')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuItem
onClick={() => {
setCurrentRow(redemption)
setOpen('update')
}}
disabled={!canEdit}
<DropdownMenuTrigger
render={
<Button
variant='ghost'
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
/>
}
>
{t('Edit')}
<DropdownMenuShortcut>
<Edit size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{canToggle && (
<DropdownMenuItem onClick={handleToggleStatus}>
{isEnabled ? (
<>
{t('Disable')}
<DropdownMenuShortcut>
<PowerOff size={16} />
</DropdownMenuShortcut>
</>
) : (
<>
{t('Enable')}
<DropdownMenuShortcut>
<Power size={16} />
</DropdownMenuShortcut>
</>
)}
<DotsHorizontalIcon className='h-4 w-4' />
<span className='sr-only'>{t('Open menu')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuItem
onClick={() => {
setCurrentRow(redemption)
setOpen('update')
}}
disabled={!canEdit}
>
{t('Edit')}
<DropdownMenuShortcut>
<Edit size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setCurrentRow(redemption)
setOpen('delete')
}}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{canToggle && (
<DropdownMenuItem onClick={handleToggleStatus}>
{isEnabled ? (
<>
{t('Disable')}
<DropdownMenuShortcut>
<PowerOff size={16} />
</DropdownMenuShortcut>
</>
) : (
<>
{t('Enable')}
<DropdownMenuShortcut>
<Power size={16} />
</DropdownMenuShortcut>
</>
)}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setCurrentRow(redemption)
setOpen('delete')
}}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -40,43 +40,43 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
return (
<div className='-ml-2'>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant='ghost' className='h-8 w-8 p-0' />}
>
<MoreHorizontal className='h-4 w-4' />
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
disabled={!complianceConfirmed}
onClick={() => {
setCurrentRow(row.original)
setOpen('update')
}}
<DropdownMenuTrigger
render={<Button variant='ghost' className='h-8 w-8 p-0' />}
>
<Pencil className='mr-2 h-4 w-4' />
{t('Edit')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!complianceConfirmed}
onClick={() => {
setCurrentRow(row.original)
setOpen('toggle-status')
}}
>
{row.original.plan.enabled ? (
<>
<PowerOff className='mr-2 h-4 w-4' />
{t('Disable')}
</>
) : (
<>
<Power className='mr-2 h-4 w-4' />
{t('Enable')}
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<MoreHorizontal className='h-4 w-4' />
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
disabled={!complianceConfirmed}
onClick={() => {
setCurrentRow(row.original)
setOpen('update')
}}
>
<Pencil className='mr-2 h-4 w-4' />
{t('Edit')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!complianceConfirmed}
onClick={() => {
setCurrentRow(row.original)
setOpen('toggle-status')
}}
>
{row.original.plan.enabled ? (
<>
<PowerOff className='mr-2 h-4 w-4' />
{t('Disable')}
</>
) : (
<>
<Power className='mr-2 h-4 w-4' />
{t('Enable')}
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -307,8 +307,7 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
{
id: 'url',
header: t('Uptime Kuma URL'),
cellClassName:
'text-primary max-w-xs truncate font-mono text-sm',
cellClassName: 'text-primary max-w-xs truncate font-mono text-sm',
cell: (group) => group.url,
},
{
@@ -554,7 +554,9 @@ export function ChannelAffinitySection(props: Props) {
{
id: 'model-regex',
header: t('Model Regex'),
cell: (rule) => <RuleBadgeList items={rule.model_regex || []} />,
cell: (rule) => (
<RuleBadgeList items={rule.model_regex || []} />
),
},
{
id: 'key-sources',
@@ -149,9 +149,7 @@ export function AmountDiscountVisualEditor({
id: 'amount',
header: t('Recharge Amount'),
cell: (discount) => (
<span className='font-mono text-sm'>
${discount.amount}
</span>
<span className='font-mono text-sm'>${discount.amount}</span>
),
},
{
@@ -26,9 +26,7 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
StaticDataTable,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
import { safeJsonParseWithValidation } from '../utils/json-parser'
import { isArray } from '../utils/json-validators'
import {
@@ -27,9 +27,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
StaticDataTable,
} from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table'
export type ConflictItem = {
channel: string
@@ -86,18 +84,18 @@ export function ConflictConfirmDialog({
id: 'current',
header: t('Current Billing'),
cell: (conflict) => (
<pre className='text-sm whitespace-pre-wrap'>
{conflict.current}
</pre>
<pre className='text-sm whitespace-pre-wrap'>
{conflict.current}
</pre>
),
},
{
id: 'new',
header: t('Change To'),
cell: (conflict) => (
<pre className='text-sm whitespace-pre-wrap'>
{conflict.newVal}
</pre>
<pre className='text-sm whitespace-pre-wrap'>
{conflict.newVal}
</pre>
),
},
]}
@@ -263,11 +263,11 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
id: 'tool',
header: t('Tool identifier'),
cell: (row) => (
<Input
value={row.key}
placeholder='web_search_preview:gpt-4o*'
onChange={(e) => updateRow(row.id, 'key', e.target.value)}
/>
<Input
value={row.key}
placeholder='web_search_preview:gpt-4o*'
onChange={(e) => updateRow(row.id, 'key', e.target.value)}
/>
),
},
{
@@ -275,15 +275,15 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
header: t('Price ($/1K calls)'),
className: 'w-[200px]',
cell: (row) => (
<Input
type='number'
min={0}
step={0.5}
value={row.price}
onChange={(e) =>
updateRow(row.id, 'price', Number(e.target.value) || 0)
}
/>
<Input
type='number'
min={0}
step={0.5}
value={row.price}
onChange={(e) =>
updateRow(row.id, 'price', Number(e.target.value) || 0)
}
/>
),
},
{
@@ -292,14 +292,14 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
className: 'w-[80px] text-right',
cellClassName: 'text-right',
cell: (row) => (
<Button
variant='ghost'
size='icon'
onClick={() => removeRow(row.id)}
aria-label={t('Delete')}
>
<Trash2 className='text-destructive h-4 w-4' />
</Button>
<Button
variant='ghost'
size='icon'
onClick={() => removeRow(row.id)}
aria-label={t('Delete')}
>
<Trash2 className='text-destructive h-4 w-4' />
</Button>
),
},
]}
@@ -487,7 +487,9 @@ export function DetailsDialog(props: DetailsDialogProps) {
// Channel update records which fields changed (stable field tokens); render
// them with their localized labels for admins.
const changedFieldTokens =
isManage && props.isAdmin && Array.isArray(other?.op?.params?.changed_fields)
isManage &&
props.isAdmin &&
Array.isArray(other?.op?.params?.changed_fields)
? (other.op.params.changed_fields as string[])
: []
const changedFieldsText = changedFieldTokens
@@ -44,7 +44,7 @@ export function AffiliateRewardsCard({
const { t } = useTranslation()
if (loading) {
return (
<Card className='bg-muted/20 py-0'>
<Card data-card-hover='false' className='bg-muted/20 py-0'>
<CardContent className='grid gap-4 p-3 sm:p-4 lg:grid-cols-[minmax(220px,1fr)_minmax(220px,0.72fr)_minmax(320px,1.15fr)] lg:items-center'>
<div>
<Skeleton className='h-5 w-32' />
@@ -60,7 +60,7 @@ export function AffiliateRewardsCard({
const hasRewards = (user?.aff_quota ?? 0) > 0
return (
<Card className='bg-muted/20 py-0'>
<Card data-card-hover='false' className='bg-muted/20 py-0'>
<CardContent className='grid gap-3 p-3 sm:gap-4 sm:p-4 lg:grid-cols-[minmax(200px,1fr)_minmax(180px,0.65fr)_minmax(280px,1fr)] lg:items-center'>
<div className='flex min-w-0 items-center gap-2.5'>
<div className='bg-background flex size-8 shrink-0 items-center justify-center rounded-lg border'>
@@ -55,7 +55,8 @@ export function CreemProductsSection({
{products.map((product) => (
<Card
key={product.productId}
className='hover:border-foreground/50 cursor-pointer transition-all hover:shadow-md'
data-card-hover='false'
className='cursor-pointer'
onClick={() => onProductSelect(product)}
>
<CardContent className='p-3 text-center sm:p-4'>
@@ -63,7 +64,7 @@ export function CreemProductsSection({
<div className='text-muted-foreground mb-2 text-sm'>
{t('Quota')}: {formatNumber(product.quota)}
</div>
<div className='text-lg font-semibold text-indigo-600'>
<div className='text-primary text-lg font-semibold'>
{formatCreemPrice(product.price, product.currency)}
</div>
</CardContent>
@@ -183,7 +183,7 @@ export function BillingHistoryDialog({
return (
<div
key={record.id}
className='hover:bg-muted/50 rounded-lg border p-3 transition-colors sm:p-4'
className='rounded-lg border p-3 sm:p-4'
>
{/* Header Row */}
<div className='flex items-start justify-between gap-2'>
@@ -76,7 +76,7 @@ export function CreemConfirmDialog({
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('Price')}</span>
<span className='font-medium text-indigo-600'>
<span className='text-primary font-medium'>
{formatCreemPrice(product.price, product.currency)}
</span>
</div>
@@ -139,7 +139,7 @@ export function RechargeFormCard({
if (loading) {
return (
<Card className='gap-0 overflow-hidden py-0'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<Skeleton className='h-6 w-32' />
<Skeleton className='mt-2 h-4 w-48' />
@@ -191,6 +191,7 @@ export function RechargeFormCard({
title={t('Add Funds')}
description={t('Choose an amount and payment method')}
icon={<WalletCards className='h-4 w-4' />}
disableHoverEffect
action={
onOpenBilling ? (
<Button
@@ -238,7 +239,7 @@ export function RechargeFormCard({
key={index}
variant='outline'
className={cn(
'hover:border-foreground flex min-h-16 flex-col items-start rounded-lg px-3 py-2.5 text-left whitespace-normal sm:min-h-[72px] sm:p-4',
'flex min-h-16 flex-col items-start rounded-lg px-3 py-2.5 text-left whitespace-normal sm:min-h-[72px] sm:p-4',
selectedPreset === preset.value
? 'border-foreground bg-foreground/5 dark:border-foreground dark:bg-foreground/10'
: 'border-muted'
@@ -235,7 +235,7 @@ export function SubscriptionPlansCard({
if (loading) {
return (
<Card className='gap-0 overflow-hidden py-0'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<Skeleton className='h-6 w-32' />
</CardHeader>
@@ -261,6 +261,7 @@ export function SubscriptionPlansCard({
title={t('Subscription Plans')}
description={t('Subscribe to a plan for model access')}
icon={<Crown className='h-4 w-4' />}
disableHoverEffect
contentClassName='space-y-4 sm:space-y-5'
>
{/* My subscriptions & billing preference */}
@@ -539,10 +540,8 @@ export function SubscriptionPlansCard({
return (
<Card
key={plan.id}
className={cn(
'transition-shadow hover:shadow-md',
isPopular && 'border-primary/70 shadow-sm'
)}
data-card-hover='false'
className={cn(isPopular && 'border-primary/70 shadow-sm')}
>
<CardContent className='flex h-full flex-col p-3.5 sm:p-4'>
<div className='mb-2 flex items-start justify-between gap-3'>
+3 -3
View File
@@ -477,19 +477,19 @@ For commercial licensing, please contact support@quantumnous.com
/* Micro-interactions — Vercel-style subtle hover/active feedback */
@media (prefers-reduced-motion: no-preference) {
[data-slot='card'] {
[data-slot='card']:not([data-card-hover='false']) {
transition:
transform 150ms ease,
box-shadow 150ms ease;
}
@media (min-width: 641px) {
[data-slot='card']:hover {
[data-slot='card']:not([data-card-hover='false']):hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgb(0 0 0 / 0.06);
}
.dark [data-slot='card']:hover {
.dark [data-slot='card']:not([data-card-hover='false']):hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 0.3);
}
}
+3 -3
View File
@@ -25,8 +25,8 @@ declare module '@tanstack/react-table' {
className?: string
pinned?: 'left' | 'right'
// Mobile card list layout hints (used by MobileCardList)
mobileTitle?: boolean // card title area (left, larger text)
mobileBadge?: boolean // status badge alongside title (right)
mobileHidden?: boolean // hide this column on mobile entirely
mobileTitle?: boolean // card title area (left, larger text)
mobileBadge?: boolean // status badge alongside title (right)
mobileHidden?: boolean // hide this column on mobile entirely
}
}